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