From 47f5c27eecb92d0cd4fdf155c95739b022ab3ee1 Mon Sep 17 00:00:00 2001 From: elexis Date: Sun, 6 Oct 2019 14:42:12 +0000 Subject: [PATCH] Rewrite session chat to use hierarchical object oriented design using class syntax, refs #5387. Set global hotkeys in JS instead of empty XML objects from f192d4a2fa/D2260. Differential Revision: https://code.wildfiregames.com/D2355 This was SVN commit r23062. --- .../data/mods/public/gui/session/chat/Chat.js | 87 +++ .../public/gui/session/chat/ChatAddressees.js | 127 ++++ .../public/gui/session/chat/ChatHistory.js | 134 ++++ .../mods/public/gui/session/chat/ChatInput.js | 79 +++ .../session/chat/ChatMessageFormatNetwork.js | 69 ++ .../session/chat/ChatMessageFormatPlayer.js | 166 +++++ .../chat/ChatMessageFormatSimulation.js | 157 +++++ .../gui/session/chat/ChatMessageHandler.js | 86 +++ .../public/gui/session/chat/ChatOverlay.js | 69 ++ .../public/gui/session/chat/ChatWindow.js | 94 +++ .../gui/session/{ => chat}/chat_window.xml | 23 +- .../public/gui/session/developer_overlay.js | 2 +- .../mods/public/gui/session/hotkeys/misc.xml | 12 - binaries/data/mods/public/gui/session/menu.js | 97 +-- .../data/mods/public/gui/session/messages.js | 629 +----------------- .../data/mods/public/gui/session/session.js | 34 +- .../data/mods/public/gui/session/session.xml | 3 +- 17 files changed, 1100 insertions(+), 768 deletions(-) create mode 100644 binaries/data/mods/public/gui/session/chat/Chat.js create mode 100644 binaries/data/mods/public/gui/session/chat/ChatAddressees.js create mode 100644 binaries/data/mods/public/gui/session/chat/ChatHistory.js create mode 100644 binaries/data/mods/public/gui/session/chat/ChatInput.js create mode 100644 binaries/data/mods/public/gui/session/chat/ChatMessageFormatNetwork.js create mode 100644 binaries/data/mods/public/gui/session/chat/ChatMessageFormatPlayer.js create mode 100644 binaries/data/mods/public/gui/session/chat/ChatMessageFormatSimulation.js create mode 100644 binaries/data/mods/public/gui/session/chat/ChatMessageHandler.js create mode 100644 binaries/data/mods/public/gui/session/chat/ChatOverlay.js create mode 100644 binaries/data/mods/public/gui/session/chat/ChatWindow.js rename binaries/data/mods/public/gui/session/{ => chat}/chat_window.xml (78%) diff --git a/binaries/data/mods/public/gui/session/chat/Chat.js b/binaries/data/mods/public/gui/session/chat/Chat.js new file mode 100644 index 0000000000..88fb41b699 --- /dev/null +++ b/binaries/data/mods/public/gui/session/chat/Chat.js @@ -0,0 +1,87 @@ +/** + * This class is only concerned with owning the helper classes and linking them. + * The class is not dealing with specific GUI objects and doesn't provide own handlers. + */ +class Chat +{ + constructor() + { + this.ChatWindow = new ChatWindow(); + this.ChatOverlay = new ChatOverlay(); + + this.ChatHistory = new ChatHistory(); + this.ChatHistory.registerSelectionChangeHandler(this.ChatWindow.onSelectionChange.bind(this.ChatWindow)); + + this.ChatInput = new ChatInput(); + this.ChatInput.registerChatSubmitHandler(executeNetworkCommand); + this.ChatInput.registerChatSubmitHandler(executeCheat); + this.ChatInput.registerChatSubmitHandler(this.submitChat.bind(this)); + this.ChatInput.registerChatSubmittedHandler(this.closePage.bind(this)); + + this.ChatAddressees = new ChatAddressees(); + this.ChatAddressees.registerSelectionChangeHandler(this.ChatInput.onSelectionChange.bind(this.ChatInput)); + this.ChatAddressees.registerSelectionChangeHandler(this.ChatWindow.onSelectionChange.bind(this.ChatWindow)); + + this.ChatMessageHandler = new ChatMessageHandler(); + this.ChatMessageHandler.registerMessageFormatClass(ChatMessageFormatNetwork); + this.ChatMessageHandler.registerMessageFormatClass(ChatMessageFormatSimulation); + this.ChatMessageFormatPlayer = new ChatMessageFormatPlayer(); + this.ChatMessageFormatPlayer.registerAddresseeTypes(this.ChatAddressees.AddresseeTypes); + this.ChatMessageHandler.registerMessageFormat("message", this.ChatMessageFormatPlayer); + this.ChatMessageHandler.registerMessageHandler(this.ChatOverlay.onChatMessage.bind(this.ChatOverlay)); + this.ChatMessageHandler.registerMessageHandler(this.ChatHistory.onChatMessage.bind(this.ChatHistory)); + this.ChatMessageHandler.registerMessageHandler(() => { + if (this.ChatWindow.isOpen() && this.ChatWindow.isExtended()) + this.ChatHistory.displayChatHistory(); + }); + + Engine.SetGlobalHotkey("chat", this.openPage.bind(this)); + Engine.SetGlobalHotkey("privatechat", this.openPage.bind(this)); + Engine.SetGlobalHotkey("teamchat", () => { this.openPage(g_IsObserver ? "/observers" : "/allies"); }); + } + + /** + * Called by the owner whenever g_PlayerAssignments or g_Players changed. + */ + onUpdatePlayers() + { + this.ChatAddressees.onUpdatePlayers(); + } + + openPage(command = "") + { + if (g_Disconnected) + return; + + closeOpenDialogs(); + + this.ChatAddressees.select(command); + this.ChatHistory.displayChatHistory(); + this.ChatWindow.openPage(command); + } + + closePage() + { + this.ChatWindow.closePage(); + } + + /** + * Send the given chat message. + */ + submitChat(text, command = "") + { + if (command.startsWith("/msg ")) + Engine.SetGlobalHotkey("privatechat", () => { this.openPage(command); }); + + let msg = command ? command + " " + text : text; + + if (Engine.HasNetClient()) + Engine.SendNetworkChat(msg); + else + this.ChatMessageHandler.handleMessage({ + "type": "message", + "guid": "local", + "text": msg + }); + } +} diff --git a/binaries/data/mods/public/gui/session/chat/ChatAddressees.js b/binaries/data/mods/public/gui/session/chat/ChatAddressees.js new file mode 100644 index 0000000000..20185d5fe8 --- /dev/null +++ b/binaries/data/mods/public/gui/session/chat/ChatAddressees.js @@ -0,0 +1,127 @@ +/** + * This class is concerned with building and propagating the chat addressee selection. + */ +class ChatAddressees +{ + constructor() + { + this.selectionChangeHandlers = []; + + this.chatAddressee = Engine.GetGUIObjectByName("chatAddressee"); + this.chatAddressee.onSelectionChange = this.onSelectionChange.bind(this); + } + + registerSelectionChangeHandler(handler) + { + this.selectionChangeHandlers.push(handler); + } + + onSelectionChange() + { + let selection = this.getSelection(); + for (let handler of this.selectionChangeHandlers) + handler(selection); + } + + getSelection() + { + return this.chatAddressee.list_data[this.chatAddressee.selected] || ""; + } + + select(command) + { + this.chatAddressee.selected = this.chatAddressee.list_data.indexOf(command); + } + + onUpdatePlayers() + { + // Remember previously selected item + let selectedName = this.getSelection(); + selectedName = selectedName.startsWith("/msg ") && selectedName.substr("/msg ".length); + + let addressees = this.AddresseeTypes.filter( + addresseeType => addresseeType.isSelectable()).map( + addresseeType => ({ + "label": translateWithContext("chat addressee", addresseeType.label), + "cmd": addresseeType.command + })); + + // Add playernames for private messages + let guids = sortGUIDsByPlayerID(); + for (let guid of guids) + { + if (guid == Engine.GetPlayerGUID()) + continue; + + let playerID = g_PlayerAssignments[guid].player; + + // Don't provide option for PM from observer to player + if (g_IsObserver && !isPlayerObserver(playerID)) + continue; + + let colorBox = isPlayerObserver(playerID) ? "" : colorizePlayernameHelper("■", playerID) + " "; + + addressees.push({ + "cmd": "/msg " + g_PlayerAssignments[guid].name, + "label": colorBox + g_PlayerAssignments[guid].name + }); + } + + // Select mock item if the selected addressee went offline + if (selectedName && guids.every(guid => g_PlayerAssignments[guid].name != selectedName)) + addressees.push({ + "cmd": "/msg " + selectedName, + "label": sprintf(translate("\\[OFFLINE] %(player)s"), { "player": selectedName }) + }); + + let oldChatAddressee = this.getSelection(); + this.chatAddressee.list = addressees.map(adressee => adressee.label); + this.chatAddressee.list_data = addressees.map(adressee => adressee.cmd); + this.chatAddressee.selected = Math.max(0, this.chatAddressee.list_data.indexOf(oldChatAddressee)); + } +} + +ChatAddressees.prototype.AddresseeTypes = [ + { + "command": "", + "isSelectable": () => true, + "label": markForTranslationWithContext("chat addressee", "Everyone"), + "isAddressee": () => true + }, + { + "command": "/allies", + "isSelectable": () => !g_IsObserver, + "label": markForTranslationWithContext("chat addressee", "Allies"), + "context": markForTranslationWithContext("chat message context", "Ally"), + "isAddressee": + senderID => + g_Players[senderID] && + g_Players[Engine.GetPlayerID()] && + g_Players[senderID].isMutualAlly[Engine.GetPlayerID()], + }, + { + "command": "/enemies", + "isSelectable": () => !g_IsObserver, + "label": markForTranslationWithContext("chat addressee", "Enemies"), + "context": markForTranslationWithContext("chat message context", "Enemy"), + "isAddressee": + senderID => + g_Players[senderID] && + g_Players[Engine.GetPlayerID()] && + g_Players[senderID].isEnemy[Engine.GetPlayerID()], + }, + { + "command": "/observers", + "isSelectable": () => true, + "label": markForTranslationWithContext("chat addressee", "Observers"), + "context": markForTranslationWithContext("chat message context", "Observer"), + "isAddressee": senderID => g_IsObserver + }, + { + "command": "/msg", + "isSelectable": () => false, + "label": undefined, + "context": markForTranslationWithContext("chat message context", "Private"), + "isAddressee": (senderID, addresseeGUID) => addresseeGUID == Engine.GetPlayerGUID() + } +]; diff --git a/binaries/data/mods/public/gui/session/chat/ChatHistory.js b/binaries/data/mods/public/gui/session/chat/ChatHistory.js new file mode 100644 index 0000000000..b39c335637 --- /dev/null +++ b/binaries/data/mods/public/gui/session/chat/ChatHistory.js @@ -0,0 +1,134 @@ +/** + * The objective of this class is to build a message type filter selection and + * to store and display the chat history according to that selection. + */ +class ChatHistory +{ + constructor() + { + /** + * All unparsed chat messages received since connect, including timestamp. + */ + this.chatMessages = []; + + this.selectionChangeHandlers = []; + + this.chatHistoryFilter = Engine.GetGUIObjectByName("chatHistoryFilter"); + let filters = prepareForDropdown(this.Filters.filter(chatFilter => !chatFilter.hidden)); + this.chatHistoryFilter.list = filters.text.map(text => translateWithContext("chat history filter", text)); + this.chatHistoryFilter.list_data = filters.key; + this.chatHistoryFilter.selected = 0; + this.chatHistoryFilter.onSelectionChange = this.onSelectionChange.bind(this); + + this.chatHistoryText = Engine.GetGUIObjectByName("chatHistoryText"); + } + + registerSelectionChangeHandler(handler) + { + this.selectionChangeHandlers.push(handler); + } + + /** + * Called each time the history filter changes. + */ + onSelectionChange() + { + this.displayChatHistory(); + + for (let handler of this.selectionChangeHandlers) + handler(); + } + + displayChatHistory() + { + let selected = this.chatHistoryFilter.list_data[this.chatHistoryFilter.selected]; + + this.chatHistoryText.caption = + this.chatMessages.filter(msg => msg.filter[selected]).map(msg => + Engine.ConfigDB_GetValue("user", "chat.timestamp") == "true" ? + sprintf(translate("%(time)s %(message)s"), { + "time": msg.timePrefix, + "message": msg.txt + }) : + msg.txt).join("\n"); + } + + onChatMessage(msg, formatted) + { + // Save to chat history + let historical = { + "txt": formatted, + "timePrefix": sprintf(translate("\\[%(time)s]"), { + "time": Engine.FormatMillisecondsIntoDateStringLocal(Date.now(), translate("HH:mm")) + }), + "filter": {} + }; + + // Apply the filters now before diplomacies or playerstates change + let senderID = msg.guid && g_PlayerAssignments[msg.guid] ? g_PlayerAssignments[msg.guid].player : 0; + for (let filter of this.Filters) + historical.filter[filter.key] = filter.filter(msg, senderID); + + this.chatMessages.push(historical); + } +} + +/** + * Notice only messages will be filtered that are visible to the player in the first place. + */ +ChatHistory.prototype.Filters = [ + { + "key": "all", + "text": markForTranslationWithContext("chat history filter", "Chat and notifications"), + "filter": (msg, senderID) => true + }, + { + "key": "chat", + "text": markForTranslationWithContext("chat history filter", "Chat messages"), + "filter": (msg, senderID) => msg.type == "message" + }, + { + "key": "player", + "text": markForTranslationWithContext("chat history filter", "Players chat"), + "filter": (msg, senderID) => + msg.type == "message" && + senderID > 0 && !isPlayerObserver(senderID) + }, + { + "key": "ally", + "text": markForTranslationWithContext("chat history filter", "Ally chat"), + "filter": (msg, senderID) => + msg.type == "message" && + msg.cmd && msg.cmd == "/allies" + }, + { + "key": "enemy", + "text": markForTranslationWithContext("chat history filter", "Enemy chat"), + "filter": (msg, senderID) => + msg.type == "message" && + msg.cmd && msg.cmd == "/enemies" + }, + { + "key": "observer", + "text": markForTranslationWithContext("chat history filter", "Observer chat"), + "filter": (msg, senderID) => + msg.type == "message" && + msg.cmd && msg.cmd == "/observers" + }, + { + "key": "private", + "text": markForTranslationWithContext("chat history filter", "Private chat"), + "filter": (msg, senderID) => !!msg.isVisiblePM + }, + { + "key": "gamenotifications", + "text": markForTranslationWithContext("chat history filter", "Game notifications"), + "filter": (msg, senderID) => msg.type != "message" && msg.guid === undefined + }, + { + "key": "chatnotifications", + "text": markForTranslationWithContext("chat history filter", "Network notifications"), + "filter": (msg, senderID) => msg.type != "message" && msg.guid !== undefined, + "hidden": !Engine.HasNetClient() + } +]; diff --git a/binaries/data/mods/public/gui/session/chat/ChatInput.js b/binaries/data/mods/public/gui/session/chat/ChatInput.js new file mode 100644 index 0000000000..c19254a22d --- /dev/null +++ b/binaries/data/mods/public/gui/session/chat/ChatInput.js @@ -0,0 +1,79 @@ +/** + * This class is concerned with setting up the text input field and the send button. + */ +class ChatInput +{ + constructor() + { + this.selectedCommand = ""; + this.chatSubmitHandlers = []; + this.chatSubmittedHandlers = []; + + this.chatInput = Engine.GetGUIObjectByName("chatInput"); + this.chatInput.onPress = this.submitChatInput.bind(this); + this.chatInput.onTab = this.autoComplete.bind(this); + this.chatInput.tooltip = this.getHotkeyTooltip(); + + this.sendChat = Engine.GetGUIObjectByName("sendChat"); + this.sendChat.onPress = this.submitChatInput.bind(this); + this.sendChat.tooltip = this.getHotkeyTooltip(); + } + + getHotkeyTooltip() + { + return translateWithContext("chat input", "Type the message to send.") + "\n" + + colorizeAutocompleteHotkey() + + colorizeHotkey("\n" + translate("Press %(hotkey)s to open the public chat."), "chat") + + colorizeHotkey( + "\n" + (g_IsObserver ? + translate("Press %(hotkey)s to open the observer chat.") : + translate("Press %(hotkey)s to open the ally chat.")), + "teamchat") + + colorizeHotkey("\n" + translate("Press %(hotkey)s to open the previously selected private chat."), "privatechat"); + } + + /** + * The functions registered using this function will be called sequentially + * when the user submits chat, until one of them returns true. + */ + registerChatSubmitHandler(handler) + { + this.chatSubmitHandlers.push(handler); + } + + /** + * The functions registered using this function will be called after the user submitted chat input. + */ + registerChatSubmittedHandler(handler) + { + this.chatSubmittedHandlers.push(handler); + } + + /** + * Called each time the addressee dropdown changes selection. + */ + onSelectionChange(command) + { + this.selectedCommand = command; + } + + autoComplete() + { + let playernames = []; + for (let player in g_PlayerAssignments) + playernames.push(g_PlayerAssignments[player].name); + autoCompleteText(this.chatInput, playernames); + } + + submitChatInput() + { + let text = this.chatInput.caption; + if (!text.length) + return; + + this.chatSubmitHandlers.some(handler => handler(text, this.selectedCommand)); + + for (let handler of this.chatSubmittedHandlers) + handler(); + } +} diff --git a/binaries/data/mods/public/gui/session/chat/ChatMessageFormatNetwork.js b/binaries/data/mods/public/gui/session/chat/ChatMessageFormatNetwork.js new file mode 100644 index 0000000000..e16f93710d --- /dev/null +++ b/binaries/data/mods/public/gui/session/chat/ChatMessageFormatNetwork.js @@ -0,0 +1,69 @@ +/** + * This class parses network events sent from the NetClient, such as players connecting or disconnecting from the game. + */ +class ChatMessageFormatNetwork +{ +} + +ChatMessageFormatNetwork.clientlist = class +{ + parse() + { + return getUsernameList(); + } +}; + +ChatMessageFormatNetwork.connect = class +{ + parse(msg) + { + return sprintf( + g_PlayerAssignments[msg.guid].player != -1 ? + // Translation: A player that left the game joins again + translate("%(player)s is starting to rejoin the game.") : + // Translation: A player joins the game for the first time + translate("%(player)s is starting to join the game."), + { "player": colorizePlayernameByGUID(msg.guid) }); + } +}; + +ChatMessageFormatNetwork.disconnect = class +{ + parse(msg) + { + return sprintf(translate("%(player)s has left the game."), { + "player": colorizePlayernameByGUID(msg.guid) + }); + } +}; + +ChatMessageFormatNetwork.kicked = class +{ + parse(msg) + { + return sprintf( + msg.banned ? + translate("%(username)s has been banned") : + translate("%(username)s has been kicked"), + { + "username": colorizePlayernameHelper( + msg.username, + g_Players.findIndex(p => p.name == msg.username) + ) + }); + } +}; + +ChatMessageFormatNetwork.rejoined = class +{ + parse(msg) + { + return sprintf( + g_PlayerAssignments[msg.guid].player != -1 ? + // Translation: A player that left the game joins again + translate("%(player)s has rejoined the game.") : + // Translation: A player joins the game for the first time + translate("%(player)s has joined the game."), + { "player": colorizePlayernameByGUID(msg.guid) }); + } +}; diff --git a/binaries/data/mods/public/gui/session/chat/ChatMessageFormatPlayer.js b/binaries/data/mods/public/gui/session/chat/ChatMessageFormatPlayer.js new file mode 100644 index 0000000000..ce0e8f0c38 --- /dev/null +++ b/binaries/data/mods/public/gui/session/chat/ChatMessageFormatPlayer.js @@ -0,0 +1,166 @@ +/** + * This class interprets the given message as a chat text sent by a player to a selected addressee. + * It supports the /me command, translation and acoustic notification. + */ +class ChatMessageFormatPlayer +{ + constructor() + { + this.AddresseeTypes = []; + } + + registerAddresseeTypes(types) + { + this.AddresseeTypes = this.AddresseeTypes.concat(types); + } + + parse(msg) + { + if (!msg.text) + return ""; + + let isMe = msg.text.startsWith("/me "); + if (!isMe && !this.parseMessageAddressee(msg)) + return ""; + + isMe = msg.text.startsWith("/me "); + if (isMe) + msg.text = msg.text.substr("/me ".length); + + // Translate or escape text + if (!msg.text) + return ""; + + if (msg.translate) + { + msg.text = translate(msg.text); + if (msg.translateParameters) + { + let parameters = msg.parameters || {}; + translateObjectKeys(parameters, msg.translateParameters); + msg.text = sprintf(msg.text, parameters); + } + } + else + { + msg.text = escapeText(msg.text); + + let userName = g_PlayerAssignments[Engine.GetPlayerGUID()].name; + if (userName != g_PlayerAssignments[msg.guid].name && + msg.text.toLowerCase().indexOf(splitRatingFromNick(userName).nick.toLowerCase()) != -1) + soundNotification("nick"); + } + + // GUID for players, playerID for AIs + let coloredUsername = msg.guid != -1 ? colorizePlayernameByGUID(msg.guid) : colorizePlayernameByID(msg.player); + + return sprintf(translate(this.strings[isMe ? "me" : "regular"][msg.context ? "context" : "no-context"]), { + "message": msg.text, + "context": msg.context ? translateWithContext("chat message context", msg.context) : "", + "user": coloredUsername, + "userTag": sprintf(translate("<%(user)s>"), { "user": coloredUsername }) + }); + } + + /** + * Checks if the current user is an addressee of the chatmessage sent by another player. + * Sets the context and potentially addresseeGUID of that message. + * Returns true if the message should be displayed. + */ + parseMessageAddressee(msg) + { + if (!msg.text.startsWith('/')) + return true; + + // Split addressee command and message-text + msg.cmd = msg.text.split(/\s/)[0]; + msg.text = msg.text.substr(msg.cmd.length + 1); + + // GUID is "local" in singleplayer, some string in multiplayer. + // Chat messages sent by the simulation (AI) come with the playerID. + let senderID = msg.player ? msg.player : (g_PlayerAssignments[msg.guid] || msg).player; + + let isSender = msg.guid ? + msg.guid == Engine.GetPlayerGUID() : + senderID == Engine.GetPlayerID(); + + // Parse private message + let isPM = msg.cmd == "/msg"; + let addresseeGUID; + let addresseeIndex; + if (isPM) + { + addresseeGUID = this.matchUsername(msg.text); + let addressee = g_PlayerAssignments[addresseeGUID]; + if (!addressee) + { + if (isSender) + warn("Couldn't match username: " + msg.text); + return false; + } + + // Prohibit PM if addressee and sender are identical + if (isSender && addresseeGUID == Engine.GetPlayerGUID()) + return false; + + msg.text = msg.text.substr(addressee.name.length + 1); + addresseeIndex = addressee.player; + } + + // Set context string + let addresseeType = this.AddresseeTypes.find(type => type.command == msg.cmd); + if (!addresseeType) + { + if (isSender) + warn("Unknown chat command: " + msg.cmd); + return false; + } + msg.context = addresseeType.context; + + // For observers only permit public- and observer-chat and PM to observers + if (isPlayerObserver(senderID) && + (isPM && !isPlayerObserver(addresseeIndex) || !isPM && msg.cmd != "/observers")) + return false; + + let visible = isSender || addresseeType.isAddressee(senderID, addresseeGUID); + msg.isVisiblePM = isPM && visible; + + return visible; + } + + /** + * Returns the guid of the user with the longest name that is a prefix of the given string. + */ + matchUsername(text) + { + if (!text) + return ""; + + let match = ""; + let playerGUID = ""; + for (let guid in g_PlayerAssignments) + { + let pName = g_PlayerAssignments[guid].name; + if (text.indexOf(pName + " ") == 0 && pName.length > match.length) + { + match = pName; + playerGUID = guid; + } + } + return playerGUID; + } +} + +/** + * Chatmessage shown after commands like /me or /enemies. + */ +ChatMessageFormatPlayer.prototype.strings = { + "regular": { + "context": markForTranslation("(%(context)s) %(userTag)s %(message)s"), + "no-context": markForTranslation("%(userTag)s %(message)s") + }, + "me": { + "context": markForTranslation("(%(context)s) * %(user)s %(message)s"), + "no-context": markForTranslation("* %(user)s %(message)s") + } +}; diff --git a/binaries/data/mods/public/gui/session/chat/ChatMessageFormatSimulation.js b/binaries/data/mods/public/gui/session/chat/ChatMessageFormatSimulation.js new file mode 100644 index 0000000000..4553112f5a --- /dev/null +++ b/binaries/data/mods/public/gui/session/chat/ChatMessageFormatSimulation.js @@ -0,0 +1,157 @@ +/** + * These classes construct a chat message from simulation events initiated from the GuiInterface PushNotification method. + */ +class ChatMessageFormatSimulation +{ +} + +ChatMessageFormatSimulation.attack = class +{ + parse(msg) + { + if (msg.player != g_ViewedPlayer) + return ""; + + let message = msg.targetIsDomesticAnimal ? + translate("Your livestock has been attacked by %(attacker)s!") : + translate("You have been attacked by %(attacker)s!"); + + return sprintf(message, { + "attacker": colorizePlayernameByID(msg.attacker) + }); + } +}; + +ChatMessageFormatSimulation.barter = class +{ + parse(msg) + { + if (!g_IsObserver || Engine.ConfigDB_GetValue("user", "gui.session.notifications.barter") != "true") + return ""; + + let amountsSold = {}; + amountsSold[msg.resourceSold] = msg.amountsSold; + + let amountsBought = {}; + amountsBought[msg.resourceBought] = msg.amountsBought; + + return sprintf(translate("%(player)s bartered %(amountsBought)s for %(amountsSold)s."), { + "player": colorizePlayernameByID(msg.player), + "amountsBought": getLocalizedResourceAmounts(amountsBought), + "amountsSold": getLocalizedResourceAmounts(amountsSold) + }); + } +}; + +ChatMessageFormatSimulation.diplomacy = class +{ + parse(msg) + { + let messageType; + + if (g_IsObserver) + messageType = "observer"; + else if (Engine.GetPlayerID() == msg.sourcePlayer) + messageType = "active"; + else if (Engine.GetPlayerID() == msg.targetPlayer) + messageType = "passive"; + else + return ""; + + return sprintf(translate(this.strings[messageType][msg.status]), { + "player": colorizePlayernameByID(messageType == "active" ? msg.targetPlayer : msg.sourcePlayer), + "player2": colorizePlayernameByID(messageType == "active" ? msg.sourcePlayer : msg.targetPlayer) + }); + } +}; + +ChatMessageFormatSimulation.diplomacy.prototype.strings = { + "active": { + "ally": markForTranslation("You are now allied with %(player)s."), + "enemy": markForTranslation("You are now at war with %(player)s."), + "neutral": markForTranslation("You are now neutral with %(player)s.") + }, + "passive": { + "ally": markForTranslation("%(player)s is now allied with you."), + "enemy": markForTranslation("%(player)s is now at war with you."), + "neutral": markForTranslation("%(player)s is now neutral with you.") + }, + "observer": { + "ally": markForTranslation("%(player)s is now allied with %(player2)s."), + "enemy": markForTranslation("%(player)s is now at war with %(player2)s."), + "neutral": markForTranslation("%(player)s is now neutral with %(player2)s.") + } +}; + +ChatMessageFormatSimulation.phase = class +{ + parse(msg) + { + let notifyPhase = Engine.ConfigDB_GetValue("user", "gui.session.notifications.phase"); + if (notifyPhase == "none" || msg.player != g_ViewedPlayer && !g_IsObserver && !g_Players[msg.player].isMutualAlly[g_ViewedPlayer]) + return ""; + + let message = ""; + if (notifyPhase == "all") + { + if (msg.phaseState == "started") + message = translate("%(player)s is advancing to the %(phaseName)s."); + else if (msg.phaseState == "aborted") + message = translate("The %(phaseName)s of %(player)s has been aborted."); + } + if (msg.phaseState == "completed") + message = translate("%(player)s has reached the %(phaseName)s."); + + return sprintf(message, { + "player": colorizePlayernameByID(msg.player), + "phaseName": getEntityNames(GetTechnologyData(msg.phaseName, g_Players[msg.player].civ)) + }); + } +}; + +ChatMessageFormatSimulation.playerstate = class +{ + parse(msg) + { + if (!msg.message.pluralMessage) + return sprintf(translate(msg.message), { + "player": colorizePlayernameByID(msg.players[0]) + }); + + let mPlayers = msg.players.map(playerID => colorizePlayernameByID(playerID)); + let lastPlayer = mPlayers.pop(); + + return sprintf(translatePlural(msg.message.message, msg.message.pluralMessage, msg.message.pluralCount), { + // Translation: This comma is used for separating first to penultimate elements in an enumeration. + "players": mPlayers.join(translate(", ")), + "lastPlayer": lastPlayer + }); + } +}; + +/** + * Optionally show all tributes sent in observer mode and tributes sent between allied players. + * Otherwise, only show tributes sent directly to us, and tributes that we send. + */ +ChatMessageFormatSimulation.tribute = class +{ + parse(msg) + { + let message = ""; + if (msg.targetPlayer == Engine.GetPlayerID()) + message = translate("%(player)s has sent you %(amounts)s."); + else if (msg.sourcePlayer == Engine.GetPlayerID()) + message = translate("You have sent %(player2)s %(amounts)s."); + else if (Engine.ConfigDB_GetValue("user", "gui.session.notifications.tribute") == "true" && + (g_IsObserver || g_GameAttributes.settings.LockTeams && + g_Players[msg.sourcePlayer].isMutualAlly[Engine.GetPlayerID()] && + g_Players[msg.targetPlayer].isMutualAlly[Engine.GetPlayerID()])) + message = translate("%(player)s has sent %(player2)s %(amounts)s."); + + return sprintf(message, { + "player": colorizePlayernameByID(msg.sourcePlayer), + "player2": colorizePlayernameByID(msg.targetPlayer), + "amounts": getLocalizedResourceAmounts(msg.amounts) + }); + } +}; diff --git a/binaries/data/mods/public/gui/session/chat/ChatMessageHandler.js b/binaries/data/mods/public/gui/session/chat/ChatMessageHandler.js new file mode 100644 index 0000000000..60f8e3d354 --- /dev/null +++ b/binaries/data/mods/public/gui/session/chat/ChatMessageHandler.js @@ -0,0 +1,86 @@ +/** + * The purpose of this class is to run a given chat message through parsers until + * one of them succeeds and then call all callback handlers on the result. + */ +class ChatMessageHandler +{ + constructor() + { + /** + * Each property is an array of messageformat class instances. + * The classes must have a parse function that receives a + * msg object and translates into a string. + */ + this.messageFormats = {}; + + /** + * Functions that are called each time a message was parsed. + */ + this.messageHandlers = []; + + this.registerMessageFormat("system", new ChatMessageHandler.System()); + } + + /** + * @param type - a string denoting the messagetype used by addChatMessage calls. + * @param handler - a class instance with a parse function. + */ + registerMessageFormat(type, handler) + { + if (!this.messageFormats[type]) + this.messageFormats[type] = []; + + this.messageFormats[type].push(handler); + } + + /** + * Receives a class where each enumerable owned property is a chat format + * class identified by the property name. + */ + registerMessageFormatClass(formatClass) + { + for (let type in formatClass) + this.registerMessageFormat(type, new formatClass[type]()); + } + + registerMessageHandler(handler) + { + this.messageHandlers.push(handler); + } + + handleMessage(msg) + { + let formatted = this.parseMessage(msg); + if (!formatted) + return; + + for (let handler of this.messageHandlers) + handler(msg, formatted); + } + + parseMessage(msg) + { + if (!this.messageFormats[msg.type]) + { + error("Unknown chat message type: " + uneval(msg)); + return undefined; + } + + for (let messageFormat of this.messageFormats[msg.type]) + { + let txt = messageFormat.parse(msg); + if (txt) + return txt; + } + + return undefined; + } +} + +ChatMessageHandler.System = class +{ + parse(msg) + { + return msg.txt; + } +}; diff --git a/binaries/data/mods/public/gui/session/chat/ChatOverlay.js b/binaries/data/mods/public/gui/session/chat/ChatOverlay.js new file mode 100644 index 0000000000..d764ed8d2e --- /dev/null +++ b/binaries/data/mods/public/gui/session/chat/ChatOverlay.js @@ -0,0 +1,69 @@ +/** + * This class is concerned with displaying the most recent chat messages on a screen overlay for some seconds. + */ +class ChatOverlay +{ + constructor() + { + /** + * Maximum number of lines to display simultaneously. + */ + this.chatLines = 20; + + /** + * Number of seconds after which chatmessages will disappear. + */ + this.chatTimeout = 30; + + /** + * Holds the timer-IDs used for hiding the chat after chatTimeout seconds. + */ + this.chatTimers = []; + + /** + * The currently displayed strings, limited by the given timeframe and limit above. + */ + this.chatMessages = []; + + this.chatText = Engine.GetGUIObjectByName("chatText"); + } + + /** + * Displays this message in the chat overlay and sets up the timer to remove it after a while. + */ + onChatMessage(msg, chatMessage) + { + this.chatMessages.push(chatMessage); + this.chatTimers.push(setTimeout(this.removeOldChatMessage.bind(this), this.chatTimeout * 1000)); + + if (this.chatMessages.length > this.chatLines) + this.removeOldChatMessage(); + else + this.chatText.caption = this.chatMessages.join("\n"); + } + + /** + * Empty all messages currently displayed in the chat overlay. + */ + clearChatMessages() + { + this.chatMessages = []; + this.chatText.caption = ""; + + for (let timer of this.chatTimers) + clearTimeout(timer); + + this.chatTimers = []; + } + + /** + * Called when the timer has run out for the oldest chatmessage or when the message limit is reached. + */ + removeOldChatMessage() + { + clearTimeout(this.chatTimers[0]); + this.chatTimers.shift(); + this.chatMessages.shift(); + this.chatText.caption = this.chatMessages.join("\n"); + } +} diff --git a/binaries/data/mods/public/gui/session/chat/ChatWindow.js b/binaries/data/mods/public/gui/session/chat/ChatWindow.js new file mode 100644 index 0000000000..ec0b897536 --- /dev/null +++ b/binaries/data/mods/public/gui/session/chat/ChatWindow.js @@ -0,0 +1,94 @@ +/** + * This class is concerned with opening, closing the chat page, and + * resizing it depending on whether the chat history is shown. + */ +class ChatWindow +{ + constructor() + { + this.chatInput = Engine.GetGUIObjectByName("chatInput"); + this.closeChat = Engine.GetGUIObjectByName("closeChat"); + + this.extendedChat = Engine.GetGUIObjectByName("extendedChat"); + this.chatHistoryText = Engine.GetGUIObjectByName("chatHistoryText"); + this.chatHistoryPage = Engine.GetGUIObjectByName("chatHistoryPage"); + + this.chatDialogPanel = Engine.GetGUIObjectByName("chatDialogPanel"); + this.chatDialogPanelSmallSize = Engine.GetGUIObjectByName("chatDialogPanelSmall").size; + this.chatDialogPanelLargeSize = Engine.GetGUIObjectByName("chatDialogPanelLarge").size; + + // Adjust the width so that the chat history is in the golden ratio + this.aspectRatio = (1 + Math.sqrt(5)) / 2; + + this.initPage(); + } + + initPage() + { + this.closeChat.onPress = this.closePage.bind(this); + + this.extendedChat.onPress = () => { + Engine.ConfigDB_CreateAndWriteValueToFile("user", "chat.session.extended", String(this.isExtended()), "config/user.cfg"); + this.resizeChatWindow(); + this.chatInput.focus(); + }; + + this.extendedChat.checked = Engine.ConfigDB_GetValue("user", "chat.session.extended") == "true"; + + this.resizeChatWindow(); + } + + /** + * Called if the addressee or history filter selection changed. + */ + onSelectionChange() + { + if (this.isOpen()) + this.chatInput.focus(); + } + + isOpen() + { + return !this.chatDialogPanel.hidden; + } + + isExtended() + { + return this.extendedChat.checked; + } + + openPage(command) + { + this.chatInput.focus(); + this.chatDialogPanel.hidden = false; + } + + closePage() + { + this.chatInput.caption = ""; + this.chatInput.blur(); + this.chatDialogPanel.hidden = true; + } + + resizeChatWindow() + { + // Hide/show the panel + this.chatHistoryPage.hidden = !this.isExtended(); + + // Resize the window + if (this.isExtended()) + { + this.chatDialogPanel.size = this.chatDialogPanelLargeSize; + + let chatHistoryTextSize = this.chatHistoryText.getComputedSize(); + let width = this.aspectRatio * (chatHistoryTextSize.bottom - chatHistoryTextSize.top); + + let size = this.chatDialogPanel.size; + size.left = -width / 2 - this.chatHistoryText.size.left; + size.right = width / 2 + this.chatHistoryText.size.left; + this.chatDialogPanel.size = size; + } + else + this.chatDialogPanel.size = this.chatDialogPanelSmallSize; + } +} diff --git a/binaries/data/mods/public/gui/session/chat_window.xml b/binaries/data/mods/public/gui/session/chat/chat_window.xml similarity index 78% rename from binaries/data/mods/public/gui/session/chat_window.xml rename to binaries/data/mods/public/gui/session/chat/chat_window.xml index 61e28006b6..5e81556172 100644 --- a/binaries/data/mods/public/gui/session/chat_window.xml +++ b/binaries/data/mods/public/gui/session/chat/chat_window.xml @@ -27,12 +27,11 @@ tooltip_style="sessionToolTipBold" > Filter the chat history. - updateChatHistory(); - submitChatInput(); - - let playernames = []; - for (let player in g_PlayerAssignments) - playernames.push(g_PlayerAssignments[player].name); - autoCompleteText(this, playernames); - - + /> - + Cancel - closeChat(); @@ -88,9 +78,7 @@ checked="false" style="ModernTickBox" size="50%-40 100%-38 50%-20 100%-12" - > - onToggleChatWindowExtended(); - + /> @@ -98,9 +86,8 @@ - + Send - submitChatInput(); diff --git a/binaries/data/mods/public/gui/session/developer_overlay.js b/binaries/data/mods/public/gui/session/developer_overlay.js index 9c4fe8691e..7fe92dfdd5 100644 --- a/binaries/data/mods/public/gui/session/developer_overlay.js +++ b/binaries/data/mods/public/gui/session/developer_overlay.js @@ -202,7 +202,7 @@ DeveloperOverlay.prototype.toggle = function() // Only players can send the simulation chat command if (Engine.GetPlayerID() == -1) - submitChatDirectly(message); + g_Chat.submitChat(message); else Engine.PostNetworkCommand({ "type": "aichat", diff --git a/binaries/data/mods/public/gui/session/hotkeys/misc.xml b/binaries/data/mods/public/gui/session/hotkeys/misc.xml index 89f96d65be..3634d22fd4 100644 --- a/binaries/data/mods/public/gui/session/hotkeys/misc.xml +++ b/binaries/data/mods/public/gui/session/hotkeys/misc.xml @@ -4,18 +4,6 @@ closeOpenDialogs(); - - openChat(); - - - - openChat(g_IsObserver ? "/observers" : "/allies"); - - - - openChat(g_LastChatAddressee); - - toggleGUI(); diff --git a/binaries/data/mods/public/gui/session/menu.js b/binaries/data/mods/public/gui/session/menu.js index e697324d09..ca54f5538b 100644 --- a/binaries/data/mods/public/gui/session/menu.js +++ b/binaries/data/mods/public/gui/session/menu.js @@ -136,8 +136,7 @@ function lobbyDialogButton() function chatMenuButton() { - closeOpenDialogs(); - openChat(); + g_Chat.openPage(); } function resignMenuButton() @@ -248,29 +247,6 @@ function openOptions() }); } -function openChat(command = "") -{ - if (g_Disconnected) - return; - - closeOpenDialogs(); - - let chatAddressee = Engine.GetGUIObjectByName("chatAddressee"); - chatAddressee.selected = chatAddressee.list_data.indexOf(command); - - Engine.GetGUIObjectByName("chatInput").focus(); - Engine.GetGUIObjectByName("chatDialogPanel").hidden = false; - - updateChatHistory(); -} - -function closeChat() -{ - Engine.GetGUIObjectByName("chatInput").caption = ""; - Engine.GetGUIObjectByName("chatInput").blur(); // Remove focus - Engine.GetGUIObjectByName("chatDialogPanel").hidden = true; -} - function resizeDiplomacyDialog() { let dialog = Engine.GetGUIObjectByName("diplomacyDialogPanel"); @@ -295,74 +271,6 @@ function resizeDiplomacyDialog() dialog.size = size; } -function initChatWindow() -{ - let filters = prepareForDropdown(g_ChatHistoryFilters.filter(chatFilter => !chatFilter.hidden)); - let chatHistoryFilter = Engine.GetGUIObjectByName("chatHistoryFilter"); - chatHistoryFilter.list = filters.text; - chatHistoryFilter.list_data = filters.key; - chatHistoryFilter.selected = 0; - - Engine.GetGUIObjectByName("extendedChat").checked = - Engine.ConfigDB_GetValue("user", "chat.session.extended") == "true"; - - resizeChatWindow(); -} - -function resizeChatWindow() -{ - // Hide/show the panel - let chatHistoryPage = Engine.GetGUIObjectByName("chatHistoryPage"); - let extended = Engine.GetGUIObjectByName("extendedChat").checked; - chatHistoryPage.hidden = !extended; - - // Resize the window - let chatDialogPanel = Engine.GetGUIObjectByName("chatDialogPanel"); - if (extended) - { - chatDialogPanel.size = Engine.GetGUIObjectByName("chatDialogPanelLarge").size; - // Adjust the width so that the chat history is in the golden ratio - let chatHistory = Engine.GetGUIObjectByName("chatHistory"); - let height = chatHistory.getComputedSize().bottom - chatHistory.getComputedSize().top; - let width = (1 + Math.sqrt(5)) / 2 * height; - let size = chatDialogPanel.size; - size.left = -width / 2 - chatHistory.size.left; - size.right = width / 2 + chatHistory.size.left; - chatDialogPanel.size = size; - } - else - chatDialogPanel.size = Engine.GetGUIObjectByName("chatDialogPanelSmall").size; -} - -function updateChatHistory() -{ - if (Engine.GetGUIObjectByName("chatDialogPanel").hidden || - !Engine.GetGUIObjectByName("extendedChat").checked) - return; - - let chatHistoryFilter = Engine.GetGUIObjectByName("chatHistoryFilter"); - let selected = chatHistoryFilter.list_data[chatHistoryFilter.selected]; - - Engine.GetGUIObjectByName("chatHistory").caption = - g_ChatHistory.filter(msg => msg.filter[selected]).map(msg => - Engine.ConfigDB_GetValue("user", "chat.timestamp") == "true" ? - sprintf(translate("%(time)s %(message)s"), { - "time": msg.timePrefix, - "message": msg.txt - }) : - msg.txt - ).join("\n"); -} - -function onToggleChatWindowExtended() -{ - Engine.ConfigDB_CreateAndWriteValueToFile("user", "chat.session.extended", String(Engine.GetGUIObjectByName("extendedChat").checked), "config/user.cfg"); - - resizeChatWindow(); - - Engine.GetGUIObjectByName("chatInput").focus(); -} - function openDiplomacy() { closeOpenDialogs(); @@ -1250,10 +1158,11 @@ function openManual() function closeOpenDialogs() { closeMenu(); - closeChat(); closeDiplomacy(); closeTrade(); closeObjectives(); + + g_Chat.closePage(); } function formatTributeTooltip(playerID, resourceCode, amount) diff --git a/binaries/data/mods/public/gui/session/messages.js b/binaries/data/mods/public/gui/session/messages.js index 688a321125..71055654da 100644 --- a/binaries/data/mods/public/gui/session/messages.js +++ b/binaries/data/mods/public/gui/session/messages.js @@ -3,36 +3,6 @@ */ const g_Cheats = getCheatsData(); -/** - * Number of seconds after which chatmessages will disappear. - */ -var g_ChatTimeout = 30; - -/** - * Maximum number of lines to display simultaneously. - */ -var g_ChatLines = 20; - -/** - * The currently displayed strings, limited by the given timeframe and limit above. - */ -var g_ChatMessages = []; - -/** - * All unparsed chat messages received since connect, including timestamp. - */ -var g_ChatHistory = []; - -/** - * Holds the timer-IDs used for hiding the chat after g_ChatTimeout seconds. - */ -var g_ChatTimers = []; - -/** - * Command to send to the previously selected private chat partner. - */ -var g_LastChatAddressee = ""; - /** * All tutorial messages received so far. */ @@ -97,52 +67,6 @@ var g_NetMessageTypes = { "start": msg => {} }; -var g_FormatChatMessage = { - "system": msg => msg.text, - "connect": msg => - sprintf( - g_PlayerAssignments[msg.guid].player != -1 ? - // Translation: A player that left the game joins again - translate("%(player)s is starting to rejoin the game.") : - // Translation: A player joins the game for the first time - translate("%(player)s is starting to join the game."), - { "player": colorizePlayernameByGUID(msg.guid) } - ), - "disconnect": msg => - sprintf(translate("%(player)s has left the game."), { - "player": colorizePlayernameByGUID(msg.guid) - }), - "rejoined": msg => - sprintf( - g_PlayerAssignments[msg.guid].player != -1 ? - // Translation: A player that left the game joins again - translate("%(player)s has rejoined the game.") : - // Translation: A player joins the game for the first time - translate("%(player)s has joined the game."), - { "player": colorizePlayernameByGUID(msg.guid) } - ), - "kicked": msg => - sprintf( - msg.banned ? - translate("%(username)s has been banned") : - translate("%(username)s has been kicked"), - { - "username": colorizePlayernameHelper( - msg.username, - g_Players.findIndex(p => p.name == msg.username) - ) - } - ), - "clientlist": msg => getUsernameList(), - "message": msg => formatChatCommand(msg), - "defeat-victory": msg => formatDefeatVictoryMessage(msg.message, msg.players), - "diplomacy": msg => formatDiplomacyMessage(msg), - "tribute": msg => formatTributeMessage(msg), - "barter": msg => formatBarterMessage(msg), - "attack": msg => formatAttackMessage(msg), - "phase": msg => formatPhaseMessage(msg) -}; - /** * Show a label and grey overlay or hide both on connection change. */ @@ -156,141 +80,11 @@ var g_StatusMessageTypes = { "active": msg => "" }; -/** - * Chatmessage shown after commands like /me or /enemies. - */ -var g_ChatCommands = { - "regular": { - "context": translate("(%(context)s) %(userTag)s %(message)s"), - "no-context": translate("%(userTag)s %(message)s") - }, - "me": { - "context": translate("(%(context)s) * %(user)s %(message)s"), - "no-context": translate("* %(user)s %(message)s") - } -}; - -var g_ChatAddresseeContext = { - "/team": translate("Team"), - "/allies": translate("Ally"), - "/enemies": translate("Enemy"), - "/observers": translate("Observer"), - "/msg": translate("Private") -}; - -/** - * Returns true if the current player is an addressee, given the chat message type and sender. - */ -var g_IsChatAddressee = { - "/team": senderID => - g_Players[senderID] && - g_Players[Engine.GetPlayerID()] && - g_Players[Engine.GetPlayerID()].team != -1 && - g_Players[Engine.GetPlayerID()].team == g_Players[senderID].team, - - "/allies": senderID => - g_Players[senderID] && - g_Players[Engine.GetPlayerID()] && - g_Players[senderID].isMutualAlly[Engine.GetPlayerID()], - - "/enemies": senderID => - g_Players[senderID] && - g_Players[Engine.GetPlayerID()] && - g_Players[senderID].isEnemy[Engine.GetPlayerID()], - - "/observers": senderID => - g_IsObserver, - - "/msg": (senderID, addresseeGUID) => - addresseeGUID == Engine.GetPlayerGUID() -}; - -/** - * Notice only messages will be filtered that are visible to the player in the first place. - */ -var g_ChatHistoryFilters = [ - { - "key": "all", - "text": translateWithContext("chat history filter", "Chat and notifications"), - "filter": (msg, senderID) => true - }, - { - "key": "chat", - "text": translateWithContext("chat history filter", "Chat messages"), - "filter": (msg, senderID) => msg.type == "message" - }, - { - "key": "player", - "text": translateWithContext("chat history filter", "Players chat"), - "filter": (msg, senderID) => - msg.type == "message" && - senderID > 0 && !isPlayerObserver(senderID) - }, - { - "key": "ally", - "text": translateWithContext("chat history filter", "Ally chat"), - "filter": (msg, senderID) => - msg.type == "message" && - msg.cmd && msg.cmd == "/allies" - }, - { - "key": "enemy", - "text": translateWithContext("chat history filter", "Enemy chat"), - "filter": (msg, senderID) => - msg.type == "message" && - msg.cmd && msg.cmd == "/enemies" - }, - { - "key": "observer", - "text": translateWithContext("chat history filter", "Observer chat"), - "filter": (msg, senderID) => - msg.type == "message" && - msg.cmd && msg.cmd == "/observers" - }, - { - "key": "private", - "text": translateWithContext("chat history filter", "Private chat"), - "filter": (msg, senderID) => !!msg.isVisiblePM - }, - { - "key": "gamenotifications", - "text": translateWithContext("chat history filter", "Game notifications"), - "filter": (msg, senderID) => msg.type != "message" && msg.guid === undefined - }, - { - "key": "chatnotifications", - "text": translateWithContext("chat history filter", "Network notifications"), - "filter": (msg, senderID) => msg.type != "message" && msg.guid !== undefined, - "hidden": !Engine.HasNetClient() - } -]; - var g_PlayerStateMessages = { "won": translate("You have won!"), "defeated": translate("You have been defeated!") }; -/** - * Chatmessage shown on diplomacy change. - */ -var g_DiplomacyMessages = { - "active": { - "ally": translate("You are now allied with %(player)s."), - "enemy": translate("You are now at war with %(player)s."), - "neutral": translate("You are now neutral with %(player)s.") - }, - "passive": { - "ally": translate("%(player)s is now allied with you."), - "enemy": translate("%(player)s is now at war with you."), - "neutral": translate("%(player)s is now neutral with you.") - }, - "observer": { - "ally": translate("%(player)s is now allied with %(player2)s."), - "enemy": translate("%(player)s is now at war with %(player2)s."), - "neutral": translate("%(player)s is now neutral with %(player2)s.") - } -}; - /** * Defines how the GUI reacts to notifications that are sent by the simulation. * Don't open new pages (message boxes) here! Otherwise further notifications @@ -478,7 +272,7 @@ var g_NotificationsTypes = g_Selection.reset(); g_Selection.addList(selection, false, cmd.type == "gather"); }, - "play-tracks": function (notification, player) + "play-tracks": function(notification, player) { if (notification.lock) { @@ -765,7 +559,7 @@ function handlePlayerAssignmentsMessage(message) }); updateGUIObjects(); - updateChatAddressees(); + g_Chat.onUpdatePlayers(); sendLobbyPlayerlistUpdate(); } @@ -800,178 +594,14 @@ function onClientLeave(guid) }); } -function updateChatAddressees() -{ - // Remember previously selected item - let chatAddressee = Engine.GetGUIObjectByName("chatAddressee"); - let selectedName = chatAddressee.list_data[chatAddressee.selected] || ""; - selectedName = selectedName.substr(0, 4) == "/msg" && selectedName.substr(5); - - let addressees = [ - { - "label": translateWithContext("chat addressee", "Everyone"), - "cmd": "" - } - ]; - - if (!g_IsObserver) - { - addressees.push({ - "label": translateWithContext("chat addressee", "Allies"), - "cmd": "/allies" - }); - addressees.push({ - "label": translateWithContext("chat addressee", "Enemies"), - "cmd": "/enemies" - }); - } - - addressees.push({ - "label": translateWithContext("chat addressee", "Observers"), - "cmd": "/observers" - }); - - // Add playernames for private messages - let guids = sortGUIDsByPlayerID(); - for (let guid of guids) - { - if (guid == Engine.GetPlayerGUID()) - continue; - - let playerID = g_PlayerAssignments[guid].player; - - // Don't provide option for PM from observer to player - if (g_IsObserver && !isPlayerObserver(playerID)) - continue; - - let colorBox = isPlayerObserver(playerID) ? "" : colorizePlayernameHelper("■", playerID) + " "; - - addressees.push({ - "cmd": "/msg " + g_PlayerAssignments[guid].name, - "label": colorBox + g_PlayerAssignments[guid].name - }); - } - - // Select mock item if the selected addressee went offline - if (selectedName && guids.every(guid => g_PlayerAssignments[guid].name != selectedName)) - addressees.push({ - "cmd": "/msg " + selectedName, - "label": sprintf(translate("\\[OFFLINE] %(player)s"), { "player": selectedName }) - }); - - let oldChatAddressee = chatAddressee.list_data[chatAddressee.selected]; - chatAddressee.list = addressees.map(adressee => adressee.label); - chatAddressee.list_data = addressees.map(adressee => adressee.cmd); - chatAddressee.selected = Math.max(0, chatAddressee.list_data.indexOf(oldChatAddressee)); -} - -/** - * Send text as chat. Don't look for commands. - * - * @param {string} text - */ -function submitChatDirectly(text) -{ - if (!text.length) - return; - - if (g_IsNetworked) - Engine.SendNetworkChat(text); - else - addChatMessage({ "type": "message", "guid": "local", "text": text }); -} - -/** - * Loads the text from the GUI window, checks if it is a local command - * or cheat and executes it. Otherwise sends it as chat. - */ -function submitChatInput() -{ - let text = Engine.GetGUIObjectByName("chatInput").caption; - - closeChat(); - - if (!text.length) - return; - - if (executeNetworkCommand(text)) - return; - - if (executeCheat(text)) - return; - - let chatAddressee = Engine.GetGUIObjectByName("chatAddressee"); - if (chatAddressee.selected > 0 && (text.indexOf("/") != 0 || text.indexOf("/me ") == 0)) - text = chatAddressee.list_data[chatAddressee.selected] + " " + text; - - let selectedChat = chatAddressee.list_data[chatAddressee.selected]; - if (selectedChat.startsWith("/msg")) - g_LastChatAddressee = selectedChat; - - submitChatDirectly(text); -} - -/** - * Displays the prepared chatmessage. - * - * @param msg {Object} - */ function addChatMessage(msg) { - if (!g_FormatChatMessage[msg.type]) - return; - - let formatted = g_FormatChatMessage[msg.type](msg); - if (!formatted) - return; - - // Update chat overlay - g_ChatMessages.push(formatted); - g_ChatTimers.push(setTimeout(removeOldChatMessage, g_ChatTimeout * 1000)); - - if (g_ChatMessages.length > g_ChatLines) - removeOldChatMessage(); - else - Engine.GetGUIObjectByName("chatText").caption = g_ChatMessages.join("\n"); - - // Save to chat history - let historical = { - "txt": formatted, - "timePrefix": sprintf(translate("\\[%(time)s]"), { - "time": Engine.FormatMillisecondsIntoDateStringLocal(Date.now(), translate("HH:mm")) - }), - "filter": {} - }; - - // Apply the filters now before diplomacies or playerstates change - let senderID = msg.guid && g_PlayerAssignments[msg.guid] ? g_PlayerAssignments[msg.guid].player : 0; - for (let filter of g_ChatHistoryFilters) - historical.filter[filter.key] = filter.filter(msg, senderID); - - g_ChatHistory.push(historical); - updateChatHistory(); + g_Chat.ChatMessageHandler.handleMessage(msg); } function clearChatMessages() { - g_ChatMessages.length = 0; - Engine.GetGUIObjectByName("chatText").caption = ""; - - for (let timer of g_ChatTimers) - clearTimeout(timer); - - g_ChatTimers.length = 0; -} - -/** - * Called when the timer has run out for the oldest chatmessage or when the message limit is reached. - */ -function removeOldChatMessage() -{ - clearTimeout(g_ChatTimers[0]); - g_ChatTimers.shift(); - g_ChatMessages.shift(); - Engine.GetGUIObjectByName("chatText").caption = g_ChatMessages.join("\n"); + g_Chat.ChatOverlay.clearChatMessages(); } /** @@ -1006,257 +636,6 @@ function colorizePlayernameParameters(parameters) parameters[param] = colorizePlayernameByID(parameters[param]); } -function formatDefeatVictoryMessage(message, players) -{ - if (!message.pluralMessage) - return sprintf(translate(message), { - "player": colorizePlayernameByID(players[0]) - }); - - let mPlayers = players.map(playerID => colorizePlayernameByID(playerID)); - let lastPlayer = mPlayers.pop(); - - return sprintf(translatePlural(message.message, message.pluralMessage, message.pluralCount), { - // Translation: This comma is used for separating first to penultimate elements in an enumeration. - "players": mPlayers.join(translate(", ")), - "lastPlayer": lastPlayer - }); -} - -function formatDiplomacyMessage(msg) -{ - let messageType; - - if (g_IsObserver) - messageType = "observer"; - else if (Engine.GetPlayerID() == msg.sourcePlayer) - messageType = "active"; - else if (Engine.GetPlayerID() == msg.targetPlayer) - messageType = "passive"; - else - return ""; - - return sprintf(g_DiplomacyMessages[messageType][msg.status], { - "player": colorizePlayernameByID(messageType == "active" ? msg.targetPlayer : msg.sourcePlayer), - "player2": colorizePlayernameByID(messageType == "active" ? msg.sourcePlayer : msg.targetPlayer) - }); -} - -/** - * Optionally show all tributes sent in observer mode and tributes sent between allied players. - * Otherwise, only show tributes sent directly to us, and tributes that we send. - */ -function formatTributeMessage(msg) -{ - let message = ""; - if (msg.targetPlayer == Engine.GetPlayerID()) - message = translate("%(player)s has sent you %(amounts)s."); - else if (msg.sourcePlayer == Engine.GetPlayerID()) - message = translate("You have sent %(player2)s %(amounts)s."); - else if (Engine.ConfigDB_GetValue("user", "gui.session.notifications.tribute") == "true" && - (g_IsObserver || g_GameAttributes.settings.LockTeams && - g_Players[msg.sourcePlayer].isMutualAlly[Engine.GetPlayerID()] && - g_Players[msg.targetPlayer].isMutualAlly[Engine.GetPlayerID()])) - message = translate("%(player)s has sent %(player2)s %(amounts)s."); - - return sprintf(message, { - "player": colorizePlayernameByID(msg.sourcePlayer), - "player2": colorizePlayernameByID(msg.targetPlayer), - "amounts": getLocalizedResourceAmounts(msg.amounts) - }); -} - -function formatBarterMessage(msg) -{ - if (!g_IsObserver || Engine.ConfigDB_GetValue("user", "gui.session.notifications.barter") != "true") - return ""; - - let amountsSold = {}; - amountsSold[msg.resourceSold] = msg.amountsSold; - - let amountsBought = {}; - amountsBought[msg.resourceBought] = msg.amountsBought; - - return sprintf(translate("%(player)s bartered %(amountsBought)s for %(amountsSold)s."), { - "player": colorizePlayernameByID(msg.player), - "amountsBought": getLocalizedResourceAmounts(amountsBought), - "amountsSold": getLocalizedResourceAmounts(amountsSold) - }); -} - -function formatAttackMessage(msg) -{ - if (msg.player != g_ViewedPlayer) - return ""; - - let message = msg.targetIsDomesticAnimal ? - translate("Your livestock has been attacked by %(attacker)s!") : - translate("You have been attacked by %(attacker)s!"); - - return sprintf(message, { - "attacker": colorizePlayernameByID(msg.attacker) - }); -} - -function formatPhaseMessage(msg) -{ - let notifyPhase = Engine.ConfigDB_GetValue("user", "gui.session.notifications.phase"); - if (notifyPhase == "none" || msg.player != g_ViewedPlayer && !g_IsObserver && !g_Players[msg.player].isMutualAlly[g_ViewedPlayer]) - return ""; - - let message = ""; - if (notifyPhase == "all") - { - if (msg.phaseState == "started") - message = translate("%(player)s is advancing to the %(phaseName)s."); - else if (msg.phaseState == "aborted") - message = translate("The %(phaseName)s of %(player)s has been aborted."); - } - if (msg.phaseState == "completed") - message = translate("%(player)s has reached the %(phaseName)s."); - - return sprintf(message, { - "player": colorizePlayernameByID(msg.player), - "phaseName": getEntityNames(GetTechnologyData(msg.phaseName, g_Players[msg.player].civ)) - }); -} - -function formatChatCommand(msg) -{ - if (!msg.text) - return ""; - - let isMe = msg.text.indexOf("/me ") == 0; - if (!isMe && !parseChatAddressee(msg)) - return ""; - - isMe = msg.text.indexOf("/me ") == 0; - if (isMe) - msg.text = msg.text.substr("/me ".length); - - // Translate or escape text - if (!msg.text) - return ""; - - if (msg.translate) - { - msg.text = translate(msg.text); - if (msg.translateParameters) - { - let parameters = msg.parameters || {}; - translateObjectKeys(parameters, msg.translateParameters); - msg.text = sprintf(msg.text, parameters); - } - } - else - { - msg.text = escapeText(msg.text); - - let userName = g_PlayerAssignments[Engine.GetPlayerGUID()].name; - if (userName != g_PlayerAssignments[msg.guid].name && - msg.text.toLowerCase().indexOf(splitRatingFromNick(userName).nick.toLowerCase()) != -1) - soundNotification("nick"); - } - - // GUID for players, playerID for AIs - let coloredUsername = msg.guid != -1 ? colorizePlayernameByGUID(msg.guid) : colorizePlayernameByID(msg.player); - - return sprintf(g_ChatCommands[isMe ? "me" : "regular"][msg.context ? "context" : "no-context"], { - "message": msg.text, - "context": msg.context || undefined, - "user": coloredUsername, - "userTag": sprintf(translate("<%(user)s>"), { "user": coloredUsername }) - }); -} - -/** - * Checks if the current user is an addressee of the chatmessage sent by another player. - * Sets the context and potentially addresseeGUID of that message. - * Returns true if the message should be displayed. - * - * @param {Object} msg - */ -function parseChatAddressee(msg) -{ - if (msg.text[0] != '/') - return true; - - // Split addressee command and message-text - msg.cmd = msg.text.split(/\s/)[0]; - msg.text = msg.text.substr(msg.cmd.length + 1); - - // GUID is "local" in singleplayer, some string in multiplayer. - // Chat messages sent by the simulation (AI) come with the playerID. - let senderID = msg.player ? msg.player : (g_PlayerAssignments[msg.guid] || msg).player; - - let isSender = msg.guid ? - msg.guid == Engine.GetPlayerGUID() : - senderID == Engine.GetPlayerID(); - - // Parse private message - let isPM = msg.cmd == "/msg"; - let addresseeGUID; - let addresseeIndex; - if (isPM) - { - addresseeGUID = matchUsername(msg.text); - let addressee = g_PlayerAssignments[addresseeGUID]; - if (!addressee) - { - if (isSender) - warn("Couldn't match username: " + msg.text); - return false; - } - - // Prohibit PM if addressee and sender are identical - if (isSender && addresseeGUID == Engine.GetPlayerGUID()) - return false; - - msg.text = msg.text.substr(addressee.name.length + 1); - addresseeIndex = addressee.player; - } - - // Set context string - if (!g_ChatAddresseeContext[msg.cmd]) - { - if (isSender) - warn("Unknown chat command: " + msg.cmd); - return false; - } - msg.context = g_ChatAddresseeContext[msg.cmd]; - - // For observers only permit public- and observer-chat and PM to observers - if (isPlayerObserver(senderID) && - (isPM && !isPlayerObserver(addresseeIndex) || !isPM && msg.cmd != "/observers")) - return false; - let visible = isSender || g_IsChatAddressee[msg.cmd](senderID, addresseeGUID); - msg.isVisiblePM = isPM && visible; - - return visible; -} - -/** - * Returns the guid of the user with the longest name that is a prefix of the given string. - */ -function matchUsername(text) -{ - if (!text) - return ""; - - let match = ""; - let playerGUID = ""; - for (let guid in g_PlayerAssignments) - { - let pName = g_PlayerAssignments[guid].name; - if (text.indexOf(pName + " ") == 0 && pName.length > match.length) - { - match = pName; - playerGUID = guid; - } - } - return playerGUID; -} - /** * Custom dialog response handling, usable by trigger maps. */ diff --git a/binaries/data/mods/public/gui/session/session.js b/binaries/data/mods/public/gui/session/session.js index 168a60ad4e..73820e8eea 100644 --- a/binaries/data/mods/public/gui/session/session.js +++ b/binaries/data/mods/public/gui/session/session.js @@ -10,6 +10,8 @@ const g_StartingResources = prepareForDropdown(g_Settings && g_Settings.Starting const g_VictoryDurations = prepareForDropdown(g_Settings && g_Settings.VictoryDurations); const g_VictoryConditions = g_Settings && g_Settings.VictoryConditions; +var g_Chat; + var g_GameSpeeds; /** @@ -271,13 +273,19 @@ function init(initData, hotloadData) } g_DeveloperOverlay = new DeveloperOverlay(); + g_Chat = new Chat(); + LoadModificationTemplates(); updatePlayerData(); initializeMusic(); // before changing the perspective initGUIObjects(); if (hotloadData) + { g_Selection.selected = hotloadData.selection; + g_PlayerAssignments = hotloadData.playerAssignments; + g_Players = hotloadData.player; + } sendLobbyPlayerlistUpdate(); onSimulationUpdate(); @@ -293,7 +301,6 @@ function initGUIObjects() initBarterButtons(); initPanelEntities(); initViewedPlayerDropdown(); - initChatWindow(); Engine.SetBoundingBoxDebugOverlay(false); updateEnabledRangeOverlayTypes(); } @@ -416,17 +423,6 @@ function updateDisplayedPlayerColors() */ function updateHotkeyTooltips() { - Engine.GetGUIObjectByName("chatInput").tooltip = - translateWithContext("chat input", "Type the message to send.") + "\n" + - colorizeAutocompleteHotkey() + - colorizeHotkey("\n" + translate("Press %(hotkey)s to open the public chat."), "chat") + - colorizeHotkey( - "\n" + (g_IsObserver ? - translate("Press %(hotkey)s to open the observer chat.") : - translate("Press %(hotkey)s to open the ally chat.")), - "teamchat") + - colorizeHotkey("\n" + translate("Press %(hotkey)s to open the previously selected private chat."), "privatechat"); - Engine.GetGUIObjectByName("idleWorkerButton").tooltip = colorizeHotkey("%(hotkey)s" + " ", "selection.idleworker") + translate("Find idle worker"); @@ -557,7 +553,7 @@ function selectViewPlayer(playerID) Engine.SetViewedPlayer(g_ViewedPlayer); updateDisplayedPlayerColors(); updateTopPanel(); - updateChatAddressees(); + g_Chat.onUpdatePlayers(); updateHotkeyTooltips(); // Update GUI and clear player-dependent cache @@ -605,7 +601,7 @@ function controlsPlayer(playerID) function playersFinished(players, victoryString, won) { addChatMessage({ - "type": "defeat-victory", + "type": "playerstate", "message": victoryString, "players": players }); @@ -616,7 +612,7 @@ function playersFinished(players, victoryString, won) sendLobbyPlayerlistUpdate(); updatePlayerData(); - updateChatAddressees(); + g_Chat.onUpdatePlayers(); updateGameSpeedControl(); if (players.indexOf(g_ViewedPlayer) == -1) @@ -762,7 +758,11 @@ function leaveGame(willRejoin) // Return some data that we'll use when hotloading this file after changes function getHotloadData() { - return { "selection": g_Selection.selected }; + return { + "selection": g_Selection.selected, + "playerAssignments": g_PlayerAssignments, + "player": g_Players, + }; } function getSavedGameData() @@ -837,7 +837,7 @@ function onWindowResized() // Update followPlayerLabel updateTopPanel(); - resizeChatWindow(); + g_Chat.ChatWindow.resizeChatWindow(); } function changeGameSpeed(speed) diff --git a/binaries/data/mods/public/gui/session/session.xml b/binaries/data/mods/public/gui/session/session.xml index 57125ef613..54f42feba9 100644 --- a/binaries/data/mods/public/gui/session/session.xml +++ b/binaries/data/mods/public/gui/session/session.xml @@ -4,6 +4,7 @@