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