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