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 = minimum(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 m_pHistoryEntry = m_History.Last();
492
493 if(m_pHistoryEntry)
494 m_Input.Set(m_pHistoryEntry->m_aText);
495 }
496 else if(Event.m_Flags & IInput::FLAG_PRESS && Event.m_Key == KEY_DOWN)
497 {
498 if(m_pHistoryEntry)
499 m_pHistoryEntry = m_History.Next(pCurrent: m_pHistoryEntry);
500
501 if(m_pHistoryEntry)
502 {
503 m_Input.Set(m_pHistoryEntry->m_aText);
504 }
505 else if(!m_EditingNewLine)
506 {
507 m_Input.Set(m_aCurrentInputText);
508 m_EditingNewLine = true;
509 }
510 }
511
512 return true;
513}
514
515void CChat::EnableMode(int Team)
516{
517 if(Client()->State() == IClient::STATE_DEMOPLAYBACK)
518 return;
519
520 if(m_Mode == MODE_NONE)
521 {
522 if(Team)
523 m_Mode = MODE_TEAM;
524 else
525 m_Mode = MODE_ALL;
526
527 Input()->Clear();
528 m_CompletionChosen = -1;
529 m_CompletionUsed = false;
530 m_Input.Activate(Priority: EInputPriority::CHAT);
531 }
532}
533
534void CChat::DisableMode()
535{
536 if(m_Mode != MODE_NONE)
537 {
538 m_Mode = MODE_NONE;
539 m_Input.Deactivate();
540 }
541}
542
543void CChat::OnMessage(int MsgType, void *pRawMsg)
544{
545 if(GameClient()->m_SuppressEvents)
546 return;
547
548 if(MsgType == NETMSGTYPE_SV_CHAT)
549 {
550 CNetMsg_Sv_Chat *pMsg = (CNetMsg_Sv_Chat *)pRawMsg;
551
552 /*
553 if(g_Config.m_ClCensorChat)
554 {
555 char aMessage[MAX_LINE_LENGTH];
556 str_copy(aMessage, pMsg->m_pMessage);
557 GameClient()->m_Censor.CensorMessage(aMessage);
558 AddLine(pMsg->m_ClientId, pMsg->m_Team, aMessage);
559 }
560 else
561 AddLine(pMsg->m_ClientId, pMsg->m_Team, pMsg->m_pMessage);
562 */
563
564 AddLine(ClientId: pMsg->m_ClientId, Team: pMsg->m_Team, pLine: pMsg->m_pMessage);
565
566 if(Client()->State() != IClient::STATE_DEMOPLAYBACK &&
567 pMsg->m_ClientId == SERVER_MSG)
568 {
569 StoreSave(pText: pMsg->m_pMessage);
570 }
571 }
572 else if(MsgType == NETMSGTYPE_SV_COMMANDINFO)
573 {
574 CNetMsg_Sv_CommandInfo *pMsg = (CNetMsg_Sv_CommandInfo *)pRawMsg;
575 if(!m_ServerSupportsCommandInfo)
576 {
577 m_vServerCommands.clear();
578 m_ServerSupportsCommandInfo = true;
579 }
580 RegisterCommand(pName: pMsg->m_pName, pParams: pMsg->m_pArgsFormat, pHelpText: pMsg->m_pHelpText);
581 }
582 else if(MsgType == NETMSGTYPE_SV_COMMANDINFOREMOVE)
583 {
584 CNetMsg_Sv_CommandInfoRemove *pMsg = (CNetMsg_Sv_CommandInfoRemove *)pRawMsg;
585 UnregisterCommand(pName: pMsg->m_pName);
586 }
587}
588
589bool CChat::LineShouldHighlight(const char *pLine, const char *pName)
590{
591 const char *pHit = str_utf8_find_nocase(haystack: pLine, needle: pName);
592
593 while(pHit)
594 {
595 int Length = str_length(str: pName);
596
597 if(Length > 0 && (pLine == pHit || pHit[-1] == ' ') && (pHit[Length] == 0 || pHit[Length] == ' ' || pHit[Length] == '.' || pHit[Length] == '!' || pHit[Length] == ',' || pHit[Length] == '?' || pHit[Length] == ':'))
598 return true;
599
600 pHit = str_utf8_find_nocase(haystack: pHit + 1, needle: pName);
601 }
602
603 return false;
604}
605
606static constexpr const char *SAVES_HEADER[] = {
607 "Time",
608 "Player",
609 "Map",
610 "Code",
611};
612
613// TODO: remove this in a few releases (in 2027 or later)
614// it got deprecated by CGameClient::StoreSave
615void CChat::StoreSave(const char *pText)
616{
617 const char *pStart = str_find(haystack: pText, needle: "Team successfully saved by ");
618 const char *pMid = str_find(haystack: pText, needle: ". Use '/load ");
619 const char *pOn = str_find(haystack: pText, needle: "' on ");
620 const char *pEnd = str_find(haystack: pText, needle: pOn ? " to continue" : "' to continue");
621
622 if(!pStart || !pMid || !pEnd || pMid < pStart || pEnd < pMid || (pOn && (pOn < pMid || pEnd < pOn)))
623 return;
624
625 char aName[16];
626 str_truncate(dst: aName, dst_size: sizeof(aName), src: pStart + 27, truncation_len: pMid - pStart - 27);
627
628 char aSaveCode[64];
629
630 str_truncate(dst: aSaveCode, dst_size: sizeof(aSaveCode), src: pMid + 13, truncation_len: (pOn ? pOn : pEnd) - pMid - 13);
631
632 char aTimestamp[20];
633 str_timestamp_format(buffer: aTimestamp, buffer_size: sizeof(aTimestamp), format: TimestampFormat::SPACE);
634
635 const bool SavesFileExists = Storage()->FileExists(pFilename: SAVES_FILE, Type: IStorage::TYPE_SAVE);
636 IOHANDLE File = Storage()->OpenFile(pFilename: SAVES_FILE, Flags: IOFLAG_APPEND, Type: IStorage::TYPE_SAVE);
637 if(!File)
638 return;
639
640 const char *apColumns[4] = {
641 aTimestamp,
642 aName,
643 GameClient()->Map()->BaseName(),
644 aSaveCode,
645 };
646
647 if(!SavesFileExists)
648 {
649 CsvWrite(File, NumColumns: 4, ppColumns: SAVES_HEADER);
650 }
651 CsvWrite(File, NumColumns: 4, ppColumns: apColumns);
652 io_close(io: File);
653}
654
655void CChat::AddLine(int ClientId, int Team, const char *pLine)
656{
657 if(*pLine == 0 ||
658 (ClientId == SERVER_MSG && !g_Config.m_ClShowChatSystem) ||
659 (ClientId >= 0 && (GameClient()->m_aClients[ClientId].m_aName[0] == '\0' || // unknown client
660 GameClient()->m_aClients[ClientId].m_ChatIgnore ||
661 (GameClient()->m_Snap.m_LocalClientId != ClientId && g_Config.m_ClShowChatFriends && !GameClient()->m_aClients[ClientId].m_Friend) ||
662 (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) ||
663 (GameClient()->m_Snap.m_LocalClientId != ClientId && GameClient()->m_aClients[ClientId].m_Foe))))
664 return;
665
666 // trim right and set maximum length to 256 utf8-characters
667 int Length = 0;
668 const char *pStr = pLine;
669 const char *pEnd = nullptr;
670 while(*pStr)
671 {
672 const char *pStrOld = pStr;
673 int Code = str_utf8_decode(ptr: &pStr);
674
675 // check if unicode is not empty
676 if(!str_utf8_isspace(code: Code))
677 {
678 pEnd = nullptr;
679 }
680 else if(pEnd == nullptr)
681 pEnd = pStrOld;
682
683 if(++Length >= MAX_LINE_LENGTH)
684 {
685 *(const_cast<char *>(pStr)) = '\0';
686 break;
687 }
688 }
689 if(pEnd != nullptr)
690 *(const_cast<char *>(pEnd)) = '\0';
691
692 if(*pLine == 0)
693 return;
694
695 bool Highlighted = false;
696
697 auto &&FChatMsgCheckAndPrint = [this](const CLine &Line) {
698 char aBuf[1024];
699 str_format(buffer: aBuf, buffer_size: sizeof(aBuf), format: "%s%s%s", Line.m_aName, Line.m_ClientId >= 0 ? ": " : "", Line.m_aText);
700
701 ColorRGBA ChatLogColor = ColorRGBA(1.0f, 1.0f, 1.0f, 1.0f);
702 if(Line.m_Highlighted)
703 {
704 ChatLogColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageHighlightColor));
705 }
706 else
707 {
708 if(Line.m_Friend && g_Config.m_ClMessageFriend)
709 ChatLogColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageFriendColor));
710 else if(Line.m_Team)
711 ChatLogColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageTeamColor));
712 else if(Line.m_ClientId == SERVER_MSG)
713 ChatLogColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageSystemColor));
714 else if(Line.m_ClientId == CLIENT_MSG)
715 ChatLogColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageClientColor));
716 else // regular message
717 ChatLogColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageColor));
718 }
719
720 const char *pFrom;
721 if(Line.m_Whisper)
722 pFrom = "chat/whisper";
723 else if(Line.m_Team)
724 pFrom = "chat/team";
725 else if(Line.m_ClientId == SERVER_MSG)
726 pFrom = "chat/server";
727 else if(Line.m_ClientId == CLIENT_MSG)
728 pFrom = "chat/client";
729 else
730 pFrom = "chat/all";
731
732 Console()->Print(Level: IConsole::OUTPUT_LEVEL_STANDARD, pFrom, pStr: aBuf, PrintColor: ChatLogColor);
733 };
734
735 // Custom color for new line
736 std::optional<ColorRGBA> CustomColor = std::nullopt;
737 if(ClientId == CLIENT_MSG)
738 CustomColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageClientColor));
739
740 CLine &PreviousLine = m_aLines[m_CurrentLine];
741
742 // Team Number:
743 // 0 = global; 1 = team; 2 = sending whisper; 3 = receiving whisper
744
745 // If it's a client message, m_aText will have ": " prepended so we have to work around it.
746 if(PreviousLine.m_Initialized &&
747 PreviousLine.m_TeamNumber == Team &&
748 PreviousLine.m_ClientId == ClientId &&
749 str_comp(a: PreviousLine.m_aText, b: pLine) == 0 &&
750 PreviousLine.m_CustomColor == CustomColor)
751 {
752 PreviousLine.m_TimesRepeated++;
753 TextRender()->DeleteTextContainer(TextContainerIndex&: PreviousLine.m_TextContainerIndex);
754 Graphics()->DeleteQuadContainer(ContainerIndex&: PreviousLine.m_QuadContainerIndex);
755 PreviousLine.m_Time = time();
756 PreviousLine.m_aYOffset[0] = -1.0f;
757 PreviousLine.m_aYOffset[1] = -1.0f;
758
759 FChatMsgCheckAndPrint(PreviousLine);
760 return;
761 }
762
763 m_CurrentLine = (m_CurrentLine + 1) % MAX_LINES;
764
765 CLine &CurrentLine = m_aLines[m_CurrentLine];
766 CurrentLine.Reset(This&: *this);
767 CurrentLine.m_Initialized = true;
768 CurrentLine.m_Time = time();
769 CurrentLine.m_aYOffset[0] = -1.0f;
770 CurrentLine.m_aYOffset[1] = -1.0f;
771 CurrentLine.m_ClientId = ClientId;
772 CurrentLine.m_TeamNumber = Team;
773 CurrentLine.m_Team = Team == 1;
774 CurrentLine.m_Whisper = Team >= 2;
775 CurrentLine.m_NameColor = -2;
776 CurrentLine.m_CustomColor = CustomColor;
777
778 // check for highlighted name
779 if(Client()->State() != IClient::STATE_DEMOPLAYBACK)
780 {
781 if(ClientId >= 0 && ClientId != GameClient()->m_aLocalIds[0] && ClientId != GameClient()->m_aLocalIds[1])
782 {
783 for(int LocalId : GameClient()->m_aLocalIds)
784 {
785 Highlighted |= LocalId >= 0 && LineShouldHighlight(pLine, pName: GameClient()->m_aClients[LocalId].m_aName);
786 }
787 }
788 }
789 else
790 {
791 // on demo playback use local id from snap directly,
792 // since m_aLocalIds isn't valid there
793 Highlighted |= GameClient()->m_Snap.m_LocalClientId >= 0 && LineShouldHighlight(pLine, pName: GameClient()->m_aClients[GameClient()->m_Snap.m_LocalClientId].m_aName);
794 }
795 CurrentLine.m_Highlighted = Highlighted;
796
797 str_copy(dst&: CurrentLine.m_aText, src: pLine);
798
799 if(CurrentLine.m_ClientId == SERVER_MSG)
800 {
801 str_copy(dst&: CurrentLine.m_aName, src: "*** ");
802 }
803 else if(CurrentLine.m_ClientId == CLIENT_MSG)
804 {
805 str_copy(dst&: CurrentLine.m_aName, src: "— ");
806 }
807 else
808 {
809 const auto &LineAuthor = GameClient()->m_aClients[CurrentLine.m_ClientId];
810
811 if(LineAuthor.m_Active)
812 {
813 if(LineAuthor.m_Team == TEAM_SPECTATORS)
814 CurrentLine.m_NameColor = TEAM_SPECTATORS;
815
816 if(GameClient()->IsTeamPlay())
817 {
818 if(LineAuthor.m_Team == TEAM_RED)
819 CurrentLine.m_NameColor = TEAM_RED;
820 else if(LineAuthor.m_Team == TEAM_BLUE)
821 CurrentLine.m_NameColor = TEAM_BLUE;
822 }
823 }
824
825 if(Team == TEAM_WHISPER_SEND)
826 {
827 str_copy(dst&: CurrentLine.m_aName, src: "→");
828 if(LineAuthor.m_Active)
829 {
830 str_append(dst&: CurrentLine.m_aName, src: " ");
831 str_append(dst&: CurrentLine.m_aName, src: LineAuthor.m_aName);
832 }
833 CurrentLine.m_NameColor = TEAM_BLUE;
834 CurrentLine.m_Highlighted = false;
835 Highlighted = false;
836 }
837 else if(Team == TEAM_WHISPER_RECV)
838 {
839 str_copy(dst&: CurrentLine.m_aName, src: "←");
840 if(LineAuthor.m_Active)
841 {
842 str_append(dst&: CurrentLine.m_aName, src: " ");
843 str_append(dst&: CurrentLine.m_aName, src: LineAuthor.m_aName);
844 }
845 CurrentLine.m_NameColor = TEAM_RED;
846 CurrentLine.m_Highlighted = true;
847 Highlighted = true;
848 }
849 else
850 {
851 str_copy(dst&: CurrentLine.m_aName, src: LineAuthor.m_aName);
852 }
853
854 if(LineAuthor.m_Active)
855 {
856 CurrentLine.m_Friend = LineAuthor.m_Friend;
857 CurrentLine.m_pManagedTeeRenderInfo = GameClient()->CreateManagedTeeRenderInfo(Client: LineAuthor);
858 }
859 }
860
861 FChatMsgCheckAndPrint(CurrentLine);
862
863 // play sound
864 int64_t Now = time();
865 if(ClientId == SERVER_MSG)
866 {
867 if(Now - m_aLastSoundPlayed[CHAT_SERVER] >= time_freq() * 3 / 10)
868 {
869 if(g_Config.m_SndServerMessage)
870 {
871 GameClient()->m_Sounds.Play(Channel: CSounds::CHN_GUI, SetId: SOUND_CHAT_SERVER, Volume: 1.0f);
872 m_aLastSoundPlayed[CHAT_SERVER] = Now;
873 }
874 }
875 }
876 else if(ClientId == CLIENT_MSG)
877 {
878 // No sound yet
879 }
880 else if(Highlighted && Client()->State() != IClient::STATE_DEMOPLAYBACK)
881 {
882 if(Now - m_aLastSoundPlayed[CHAT_HIGHLIGHT] >= time_freq() * 3 / 10)
883 {
884 char aBuf[1024];
885 str_format(buffer: aBuf, buffer_size: sizeof(aBuf), format: "%s: %s", CurrentLine.m_aName, CurrentLine.m_aText);
886 Client()->Notify(pTitle: "DDNet Chat", pMessage: aBuf);
887 if(g_Config.m_SndHighlight)
888 {
889 GameClient()->m_Sounds.Play(Channel: CSounds::CHN_GUI, SetId: SOUND_CHAT_HIGHLIGHT, Volume: 1.0f);
890 m_aLastSoundPlayed[CHAT_HIGHLIGHT] = Now;
891 }
892
893 if(g_Config.m_ClEditor)
894 {
895 GameClient()->Editor()->UpdateMentions();
896 }
897 }
898 }
899 else if(Team != TEAM_WHISPER_SEND)
900 {
901 if(Now - m_aLastSoundPlayed[CHAT_CLIENT] >= time_freq() * 3 / 10)
902 {
903 bool PlaySound = CurrentLine.m_Team ? g_Config.m_SndTeamChat : g_Config.m_SndChat;
904#if defined(CONF_VIDEORECORDER)
905 if(IVideo::Current())
906 {
907 PlaySound &= (bool)g_Config.m_ClVideoShowChat;
908 }
909#endif
910 if(PlaySound)
911 {
912 GameClient()->m_Sounds.Play(Channel: CSounds::CHN_GUI, SetId: SOUND_CHAT_CLIENT, Volume: 1.0f);
913 m_aLastSoundPlayed[CHAT_CLIENT] = Now;
914 }
915 }
916 }
917}
918
919void CChat::OnPrepareLines(float y)
920{
921 float x = 5.0f;
922 float FontSize = this->FontSize();
923
924 const bool IsScoreBoardOpen = GameClient()->m_Scoreboard.IsActive() && (Graphics()->ScreenAspect() > 1.7f); // only assume scoreboard when screen ratio is widescreen(something around 16:9)
925 const bool ShowLargeArea = m_Show || (m_Mode != MODE_NONE && g_Config.m_ClShowChat == 1) || g_Config.m_ClShowChat == 2;
926 const bool ForceRecreate = IsScoreBoardOpen != m_PrevScoreBoardShowed || ShowLargeArea != m_PrevShowChat;
927 m_PrevScoreBoardShowed = IsScoreBoardOpen;
928 m_PrevShowChat = ShowLargeArea;
929
930 const int TeeSize = MessageTeeSize();
931 float RealMsgPaddingX = MessagePaddingX();
932 float RealMsgPaddingY = MessagePaddingY();
933 float RealMsgPaddingTee = TeeSize + MESSAGE_TEE_PADDING_RIGHT;
934
935 if(g_Config.m_ClChatOld)
936 {
937 RealMsgPaddingX = 0;
938 RealMsgPaddingY = 0;
939 RealMsgPaddingTee = 0;
940 }
941
942 int64_t Now = time();
943 float LineWidth = (IsScoreBoardOpen ? maximum(a: 85.0f, b: (FontSize * 85.0f / 6.0f)) : g_Config.m_ClChatWidth) - (RealMsgPaddingX * 1.5f) - RealMsgPaddingTee;
944
945 float HeightLimit = IsScoreBoardOpen ? 180.0f : (m_PrevShowChat ? 50.0f : 200.0f);
946 float Begin = x;
947 float TextBegin = Begin + RealMsgPaddingX / 2.0f;
948 int OffsetType = IsScoreBoardOpen ? 1 : 0;
949
950 for(int i = 0; i < MAX_LINES; i++)
951 {
952 CLine &Line = m_aLines[((m_CurrentLine - i) + MAX_LINES) % MAX_LINES];
953 if(!Line.m_Initialized)
954 break;
955 if(Now > Line.m_Time + 16 * time_freq() && !m_PrevShowChat)
956 break;
957
958 if(Line.m_TextContainerIndex.Valid() && !ForceRecreate)
959 continue;
960
961 TextRender()->DeleteTextContainer(TextContainerIndex&: Line.m_TextContainerIndex);
962 Graphics()->DeleteQuadContainer(ContainerIndex&: Line.m_QuadContainerIndex);
963
964 char aClientId[16] = "";
965 if(g_Config.m_ClShowIds && Line.m_ClientId >= 0 && Line.m_aName[0] != '\0')
966 {
967 GameClient()->FormatClientId(ClientId: Line.m_ClientId, aClientId, Format: EClientIdFormat::INDENT_AUTO);
968 }
969
970 char aCount[12];
971 if(Line.m_ClientId < 0)
972 str_format(buffer: aCount, buffer_size: sizeof(aCount), format: "[%d] ", Line.m_TimesRepeated + 1);
973 else
974 str_format(buffer: aCount, buffer_size: sizeof(aCount), format: " [%d]", Line.m_TimesRepeated + 1);
975
976 const char *pText = Line.m_aText;
977 if(Config()->m_ClStreamerMode && Line.m_ClientId == SERVER_MSG)
978 {
979 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: "'"))
980 {
981 pText = "Team save in progress. You'll be able to load with '/load *** *** ***'";
982 }
983 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"))
984 {
985 pText = "Team save in progress. You'll be able to load with '/load *** *** ***' if save is successful or with '/load *** *** ***' if it fails";
986 }
987 else if(str_startswith(str: Line.m_aText, prefix: "Team successfully saved by ") && str_endswith(str: Line.m_aText, suffix: " to continue"))
988 {
989 pText = "Team successfully saved by ***. Use '/load *** *** ***' to continue";
990 }
991 }
992
993 // get the y offset (calculate it if we haven't done that yet)
994 if(Line.m_aYOffset[OffsetType] < 0.0f)
995 {
996 CTextCursor MeasureCursor;
997 MeasureCursor.SetPosition(vec2(TextBegin, 0.0f));
998 MeasureCursor.m_FontSize = FontSize;
999 MeasureCursor.m_Flags = 0;
1000 MeasureCursor.m_LineWidth = LineWidth;
1001
1002 if(Line.m_ClientId >= 0 && Line.m_aName[0] != '\0')
1003 {
1004 MeasureCursor.m_X += RealMsgPaddingTee;
1005
1006 if(Line.m_Friend && g_Config.m_ClMessageFriend)
1007 {
1008 TextRender()->TextEx(pCursor: &MeasureCursor, pText: "♥ ");
1009 }
1010 }
1011
1012 TextRender()->TextEx(pCursor: &MeasureCursor, pText: aClientId);
1013 TextRender()->TextEx(pCursor: &MeasureCursor, pText: Line.m_aName);
1014 if(Line.m_TimesRepeated > 0)
1015 TextRender()->TextEx(pCursor: &MeasureCursor, pText: aCount);
1016
1017 if(Line.m_ClientId >= 0 && Line.m_aName[0] != '\0')
1018 {
1019 TextRender()->TextEx(pCursor: &MeasureCursor, pText: ": ");
1020 }
1021
1022 CTextCursor AppendCursor = MeasureCursor;
1023 AppendCursor.m_LongestLineWidth = 0.0f;
1024 if(!IsScoreBoardOpen && !g_Config.m_ClChatOld)
1025 {
1026 AppendCursor.m_StartX = MeasureCursor.m_X;
1027 AppendCursor.m_LineWidth -= MeasureCursor.m_LongestLineWidth;
1028 }
1029
1030 TextRender()->TextEx(pCursor: &AppendCursor, pText);
1031
1032 Line.m_aYOffset[OffsetType] = AppendCursor.Height() + RealMsgPaddingY;
1033 }
1034
1035 y -= Line.m_aYOffset[OffsetType];
1036
1037 // cut off if msgs waste too much space
1038 if(y < HeightLimit)
1039 break;
1040
1041 // the position the text was created
1042 Line.m_TextYOffset = y + RealMsgPaddingY / 2.0f;
1043
1044 int CurRenderFlags = TextRender()->GetRenderFlags();
1045 TextRender()->SetRenderFlags(CurRenderFlags | ETextRenderFlags::TEXT_RENDER_FLAG_NO_AUTOMATIC_QUAD_UPLOAD);
1046
1047 // reset the cursor
1048 CTextCursor LineCursor;
1049 LineCursor.SetPosition(vec2(TextBegin, Line.m_TextYOffset));
1050 LineCursor.m_FontSize = FontSize;
1051 LineCursor.m_LineWidth = LineWidth;
1052
1053 // Message is from valid player
1054 if(Line.m_ClientId >= 0 && Line.m_aName[0] != '\0')
1055 {
1056 LineCursor.m_X += RealMsgPaddingTee;
1057
1058 if(Line.m_Friend && g_Config.m_ClMessageFriend)
1059 {
1060 TextRender()->TextColor(Color: color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageFriendColor)).WithAlpha(alpha: 1.0f));
1061 TextRender()->CreateOrAppendTextContainer(TextContainerIndex&: Line.m_TextContainerIndex, pCursor: &LineCursor, pText: "♥ ");
1062 }
1063 }
1064
1065 // render name
1066 ColorRGBA NameColor;
1067 if(Line.m_CustomColor)
1068 NameColor = *Line.m_CustomColor;
1069 else if(Line.m_ClientId == SERVER_MSG)
1070 NameColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageSystemColor));
1071 else if(Line.m_ClientId == CLIENT_MSG)
1072 NameColor = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageClientColor));
1073 else if(Line.m_Team)
1074 NameColor = CalculateNameColor(TextColorHSL: ColorHSLA(g_Config.m_ClMessageTeamColor));
1075 else if(Line.m_NameColor == TEAM_RED)
1076 NameColor = ColorRGBA(1.0f, 0.5f, 0.5f, 1.0f);
1077 else if(Line.m_NameColor == TEAM_BLUE)
1078 NameColor = ColorRGBA(0.7f, 0.7f, 1.0f, 1.0f);
1079 else if(Line.m_NameColor == TEAM_SPECTATORS)
1080 NameColor = ColorRGBA(0.75f, 0.5f, 0.75f, 1.0f);
1081 else if(Line.m_ClientId >= 0 && g_Config.m_ClChatTeamColors && GameClient()->m_Teams.Team(ClientId: Line.m_ClientId))
1082 NameColor = GameClient()->GetDDTeamColor(DDTeam: GameClient()->m_Teams.Team(ClientId: Line.m_ClientId), Lightness: 0.75f);
1083 else
1084 NameColor = ColorRGBA(0.8f, 0.8f, 0.8f, 1.0f);
1085
1086 TextRender()->TextColor(Color: NameColor);
1087 TextRender()->CreateOrAppendTextContainer(TextContainerIndex&: Line.m_TextContainerIndex, pCursor: &LineCursor, pText: aClientId);
1088 TextRender()->CreateOrAppendTextContainer(TextContainerIndex&: Line.m_TextContainerIndex, pCursor: &LineCursor, pText: Line.m_aName);
1089
1090 if(Line.m_TimesRepeated > 0)
1091 {
1092 TextRender()->TextColor(r: 1.0f, g: 1.0f, b: 1.0f, a: 0.3f);
1093 TextRender()->CreateOrAppendTextContainer(TextContainerIndex&: Line.m_TextContainerIndex, pCursor: &LineCursor, pText: aCount);
1094 }
1095
1096 if(Line.m_ClientId >= 0 && Line.m_aName[0] != '\0')
1097 {
1098 TextRender()->TextColor(Color: NameColor);
1099 TextRender()->CreateOrAppendTextContainer(TextContainerIndex&: Line.m_TextContainerIndex, pCursor: &LineCursor, pText: ": ");
1100 }
1101
1102 ColorRGBA Color;
1103 if(Line.m_CustomColor)
1104 Color = *Line.m_CustomColor;
1105 else if(Line.m_ClientId == SERVER_MSG)
1106 Color = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageSystemColor));
1107 else if(Line.m_ClientId == CLIENT_MSG)
1108 Color = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageClientColor));
1109 else if(Line.m_Highlighted)
1110 Color = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageHighlightColor));
1111 else if(Line.m_Team)
1112 Color = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageTeamColor));
1113 else // regular message
1114 Color = color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClMessageColor));
1115 TextRender()->TextColor(Color);
1116
1117 CTextCursor AppendCursor = LineCursor;
1118 AppendCursor.m_LongestLineWidth = 0.0f;
1119 if(!IsScoreBoardOpen && !g_Config.m_ClChatOld)
1120 {
1121 AppendCursor.m_StartX = LineCursor.m_X;
1122 AppendCursor.m_LineWidth -= LineCursor.m_LongestLineWidth;
1123 }
1124
1125 TextRender()->CreateOrAppendTextContainer(TextContainerIndex&: Line.m_TextContainerIndex, pCursor: &AppendCursor, pText);
1126
1127 if(!g_Config.m_ClChatOld && (Line.m_aText[0] != '\0' || Line.m_aName[0] != '\0'))
1128 {
1129 float FullWidth = RealMsgPaddingX * 1.5f;
1130 if(!IsScoreBoardOpen && !g_Config.m_ClChatOld)
1131 {
1132 FullWidth += LineCursor.m_LongestLineWidth + AppendCursor.m_LongestLineWidth;
1133 }
1134 else
1135 {
1136 FullWidth += maximum(a: LineCursor.m_LongestLineWidth, b: AppendCursor.m_LongestLineWidth);
1137 }
1138 Graphics()->SetColor(r: 1, g: 1, b: 1, a: 1);
1139 Line.m_QuadContainerIndex = Graphics()->CreateRectQuadContainer(x: Begin, y, w: FullWidth, h: Line.m_aYOffset[OffsetType], r: MessageRounding(), Corners: IGraphics::CORNER_ALL);
1140 }
1141
1142 TextRender()->SetRenderFlags(CurRenderFlags);
1143 if(Line.m_TextContainerIndex.Valid())
1144 TextRender()->UploadTextContainer(TextContainerIndex: Line.m_TextContainerIndex);
1145 }
1146
1147 TextRender()->TextColor(Color: TextRender()->DefaultTextColor());
1148}
1149
1150void CChat::OnRender()
1151{
1152 if(Client()->State() != IClient::STATE_ONLINE && Client()->State() != IClient::STATE_DEMOPLAYBACK)
1153 return;
1154
1155 // send pending chat messages
1156 if(m_PendingChatCounter > 0 && m_LastChatSend + time_freq() < time())
1157 {
1158 CHistoryEntry *pEntry = m_History.Last();
1159 for(int i = m_PendingChatCounter - 1; pEntry; --i, pEntry = m_History.Prev(pCurrent: pEntry))
1160 {
1161 if(i == 0)
1162 {
1163 SendChat(Team: pEntry->m_Team, pLine: pEntry->m_aText);
1164 break;
1165 }
1166 }
1167 --m_PendingChatCounter;
1168 }
1169
1170 const float Height = 300.0f;
1171 const float Width = Height * Graphics()->ScreenAspect();
1172 Graphics()->MapScreen(TopLeftX: 0.0f, TopLeftY: 0.0f, BottomRightX: Width, BottomRightY: Height);
1173
1174 float x = 5.0f;
1175 float y = 300.0f - 20.0f * FontSize() / 6.0f;
1176 float ScaledFontSize = FontSize() * (8.0f / 6.0f);
1177 if(m_Mode != MODE_NONE)
1178 {
1179 // render chat input
1180 CTextCursor InputCursor;
1181 InputCursor.SetPosition(vec2(x, y));
1182 InputCursor.m_FontSize = ScaledFontSize;
1183 InputCursor.m_LineWidth = Width - 190.0f;
1184
1185 if(m_Mode == MODE_ALL)
1186 TextRender()->TextEx(pCursor: &InputCursor, pText: Localize(pStr: "All"));
1187 else if(m_Mode == MODE_TEAM)
1188 TextRender()->TextEx(pCursor: &InputCursor, pText: Localize(pStr: "Team"));
1189 else
1190 TextRender()->TextEx(pCursor: &InputCursor, pText: Localize(pStr: "Chat"));
1191
1192 TextRender()->TextEx(pCursor: &InputCursor, pText: ": ");
1193
1194 const float MessageMaxWidth = InputCursor.m_LineWidth - (InputCursor.m_X - InputCursor.m_StartX);
1195 const CUIRect ClippingRect = {.x: InputCursor.m_X, .y: InputCursor.m_Y, .w: MessageMaxWidth, .h: 2.25f * InputCursor.m_FontSize};
1196 const float XScale = Graphics()->ScreenWidth() / Width;
1197 const float YScale = Graphics()->ScreenHeight() / Height;
1198 Graphics()->ClipEnable(x: (int)(ClippingRect.x * XScale), y: (int)(ClippingRect.y * YScale), w: (int)(ClippingRect.w * XScale), h: (int)(ClippingRect.h * YScale));
1199
1200 float ScrollOffset = m_Input.GetScrollOffset();
1201 float ScrollOffsetChange = m_Input.GetScrollOffsetChange();
1202
1203 m_Input.Activate(Priority: EInputPriority::CHAT); // Ensure that the input is active
1204 const CUIRect InputCursorRect = {.x: InputCursor.m_X, .y: InputCursor.m_Y - ScrollOffset, .w: 0.0f, .h: 0.0f};
1205 const bool WasChanged = m_Input.WasChanged();
1206 const bool WasCursorChanged = m_Input.WasCursorChanged();
1207 const bool Changed = WasChanged || WasCursorChanged;
1208 const STextBoundingBox BoundingBox = m_Input.Render(pRect: &InputCursorRect, FontSize: InputCursor.m_FontSize, Align: TEXTALIGN_TL, Changed, LineWidth: MessageMaxWidth, LineSpacing: 0.0f);
1209
1210 Graphics()->ClipDisable();
1211
1212 // Scroll up or down to keep the caret inside the clipping rect
1213 const float CaretPositionY = m_Input.GetCaretPosition().y - ScrollOffsetChange;
1214 if(CaretPositionY < ClippingRect.y)
1215 ScrollOffsetChange -= ClippingRect.y - CaretPositionY;
1216 else if(CaretPositionY + InputCursor.m_FontSize > ClippingRect.y + ClippingRect.h)
1217 ScrollOffsetChange += CaretPositionY + InputCursor.m_FontSize - (ClippingRect.y + ClippingRect.h);
1218
1219 Ui()->DoSmoothScrollLogic(pScrollOffset: &ScrollOffset, pScrollOffsetChange: &ScrollOffsetChange, ViewPortSize: ClippingRect.h, TotalSize: BoundingBox.m_H);
1220
1221 m_Input.SetScrollOffset(ScrollOffset);
1222 m_Input.SetScrollOffsetChange(ScrollOffsetChange);
1223
1224 // Autocompletion hint
1225 if(m_Input.GetString()[0] == '/' && m_Input.GetString()[1] != '\0' && !m_vServerCommands.empty())
1226 {
1227 for(const auto &Command : m_vServerCommands)
1228 {
1229 if(str_startswith_nocase(str: Command.m_aName, prefix: m_Input.GetString() + 1))
1230 {
1231 InputCursor.m_X = InputCursor.m_X + TextRender()->TextWidth(Size: InputCursor.m_FontSize, pText: m_Input.GetString(), StrLength: -1, LineWidth: InputCursor.m_LineWidth);
1232 InputCursor.m_Y = m_Input.GetCaretPosition().y;
1233 TextRender()->TextColor(r: 1.0f, g: 1.0f, b: 1.0f, a: 0.5f);
1234 TextRender()->TextEx(pCursor: &InputCursor, pText: Command.m_aName + str_length(str: m_Input.GetString() + 1));
1235 TextRender()->TextColor(Color: TextRender()->DefaultTextColor());
1236 break;
1237 }
1238 }
1239 }
1240 }
1241
1242#if defined(CONF_VIDEORECORDER)
1243 if(!((g_Config.m_ClShowChat && !IVideo::Current()) || (g_Config.m_ClVideoShowChat && IVideo::Current())))
1244#else
1245 if(!g_Config.m_ClShowChat)
1246#endif
1247 return;
1248
1249 y -= ScaledFontSize;
1250
1251 OnPrepareLines(y);
1252
1253 bool IsScoreBoardOpen = GameClient()->m_Scoreboard.IsActive() && (Graphics()->ScreenAspect() > 1.7f); // only assume scoreboard when screen ratio is widescreen(something around 16:9)
1254
1255 int64_t Now = time();
1256 float HeightLimit = IsScoreBoardOpen ? 180.0f : (m_PrevShowChat ? 50.0f : 200.0f);
1257 int OffsetType = IsScoreBoardOpen ? 1 : 0;
1258
1259 float RealMsgPaddingX = MessagePaddingX();
1260 float RealMsgPaddingY = MessagePaddingY();
1261
1262 if(g_Config.m_ClChatOld)
1263 {
1264 RealMsgPaddingX = 0;
1265 RealMsgPaddingY = 0;
1266 }
1267
1268 for(int i = 0; i < MAX_LINES; i++)
1269 {
1270 CLine &Line = m_aLines[((m_CurrentLine - i) + MAX_LINES) % MAX_LINES];
1271 if(!Line.m_Initialized)
1272 break;
1273 if(Now > Line.m_Time + 16 * time_freq() && !m_PrevShowChat)
1274 break;
1275
1276 y -= Line.m_aYOffset[OffsetType];
1277
1278 // cut off if msgs waste too much space
1279 if(y < HeightLimit)
1280 break;
1281
1282 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;
1283
1284 // Draw backgrounds for messages in one batch
1285 if(!g_Config.m_ClChatOld)
1286 {
1287 Graphics()->TextureClear();
1288 if(Line.m_QuadContainerIndex != -1)
1289 {
1290 Graphics()->SetColor(color_cast<ColorRGBA>(hsl: ColorHSLA(g_Config.m_ClChatBackgroundColor, true)).WithMultipliedAlpha(alpha: Blend));
1291 Graphics()->RenderQuadContainerEx(ContainerIndex: Line.m_QuadContainerIndex, QuadOffset: 0, QuadDrawNum: -1, X: 0, Y: ((y + RealMsgPaddingY / 2.0f) - Line.m_TextYOffset));
1292 }
1293 }
1294
1295 if(Line.m_TextContainerIndex.Valid())
1296 {
1297 if(!g_Config.m_ClChatOld && Line.m_pManagedTeeRenderInfo != nullptr)
1298 {
1299 CTeeRenderInfo &TeeRenderInfo = Line.m_pManagedTeeRenderInfo->TeeRenderInfo();
1300 const int TeeSize = MessageTeeSize();
1301 TeeRenderInfo.m_Size = TeeSize;
1302
1303 float RowHeight = FontSize() + RealMsgPaddingY;
1304 float OffsetTeeY = TeeSize / 2.0f;
1305 float FullHeightMinusTee = RowHeight - TeeSize;
1306
1307 const CAnimState *pIdleState = CAnimState::GetIdle();
1308 vec2 OffsetToMid;
1309 CRenderTools::GetRenderTeeOffsetToRenderedTee(pAnim: pIdleState, pInfo: &TeeRenderInfo, TeeOffsetToMid&: OffsetToMid);
1310 vec2 TeeRenderPos(x + (RealMsgPaddingX + TeeSize) / 2.0f, y + OffsetTeeY + FullHeightMinusTee / 2.0f + OffsetToMid.y);
1311 RenderTools()->RenderTee(pAnim: pIdleState, pInfo: &TeeRenderInfo, Emote: EMOTE_NORMAL, Dir: vec2(1, 0.1f), Pos: TeeRenderPos, Alpha: Blend);
1312 }
1313
1314 const ColorRGBA TextColor = TextRender()->DefaultTextColor().WithMultipliedAlpha(alpha: Blend);
1315 const ColorRGBA TextOutlineColor = TextRender()->DefaultTextOutlineColor().WithMultipliedAlpha(alpha: Blend);
1316 TextRender()->RenderTextContainer(TextContainerIndex: Line.m_TextContainerIndex, TextColor, TextOutlineColor, X: 0, Y: (y + RealMsgPaddingY / 2.0f) - Line.m_TextYOffset);
1317 }
1318 }
1319}
1320
1321void CChat::EnsureCoherentFontSize() const
1322{
1323 // Adjust font size based on width
1324 if(g_Config.m_ClChatWidth / (float)g_Config.m_ClChatFontSize >= CHAT_FONTSIZE_WIDTH_RATIO)
1325 return;
1326
1327 // We want to keep a ration between font size and font width so that we don't have a weird rendering
1328 g_Config.m_ClChatFontSize = g_Config.m_ClChatWidth / CHAT_FONTSIZE_WIDTH_RATIO;
1329}
1330
1331void CChat::EnsureCoherentWidth() const
1332{
1333 // Adjust width based on font size
1334 if(g_Config.m_ClChatWidth / (float)g_Config.m_ClChatFontSize >= CHAT_FONTSIZE_WIDTH_RATIO)
1335 return;
1336
1337 // We want to keep a ration between font size and font width so that we don't have a weird rendering
1338 g_Config.m_ClChatWidth = CHAT_FONTSIZE_WIDTH_RATIO * g_Config.m_ClChatFontSize;
1339}
1340
1341// ----- send functions -----
1342
1343void CChat::SendChat(int Team, const char *pLine)
1344{
1345 // don't send empty messages
1346 if(*str_utf8_skip_whitespaces(str: pLine) == '\0')
1347 return;
1348
1349 m_LastChatSend = time();
1350
1351 if(GameClient()->Client()->IsSixup())
1352 {
1353 protocol7::CNetMsg_Cl_Say Msg7;
1354 Msg7.m_Mode = Team == 1 ? protocol7::CHAT_TEAM : protocol7::CHAT_ALL;
1355 Msg7.m_Target = -1;
1356 Msg7.m_pMessage = pLine;
1357 Client()->SendPackMsgActive(pMsg: &Msg7, Flags: MSGFLAG_VITAL, NoTranslate: true);
1358 return;
1359 }
1360
1361 // send chat message
1362 CNetMsg_Cl_Say Msg;
1363 Msg.m_Team = Team;
1364 Msg.m_pMessage = pLine;
1365 Client()->SendPackMsgActive(pMsg: &Msg, Flags: MSGFLAG_VITAL);
1366}
1367
1368void CChat::SendChatQueued(const char *pLine)
1369{
1370 if(!pLine || str_length(str: pLine) < 1)
1371 return;
1372
1373 bool AddEntry = false;
1374
1375 if(m_LastChatSend + time_freq() < time())
1376 {
1377 SendChat(Team: m_Mode == MODE_ALL ? 0 : 1, pLine);
1378 AddEntry = true;
1379 }
1380 else if(m_PendingChatCounter < 3)
1381 {
1382 ++m_PendingChatCounter;
1383 AddEntry = true;
1384 }
1385
1386 if(AddEntry)
1387 {
1388 const int Length = str_length(str: pLine);
1389 CHistoryEntry *pEntry = m_History.Allocate(Size: sizeof(CHistoryEntry) + Length);
1390 pEntry->m_Team = m_Mode == MODE_ALL ? 0 : 1;
1391 str_copy(dst: pEntry->m_aText, src: pLine, dst_size: Length + 1);
1392 }
1393}
1394