Index: binaries/data/mods/public/gui/session/chat/Chat.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/session/chat/Chat.js @@ -0,0 +1,91 @@ +/** + * This class instantiates the various subclasses and links them. + */ +class Chat +{ + constructor() + { + this.ChatWindow = new ChatWindow(); + this.ChatHistory = new ChatHistory(); + this.ChatOverlay = new ChatOverlay(); + + this.ChatSender = new ChatSender(); + this.ChatSender.registerChatSubmitHandler(executeNetworkCommand); + this.ChatSender.registerChatSubmitHandler(executeCheat); + this.ChatSender.registerChatSubmitHandler(this.submitChat.bind(this)); + this.ChatSender.registerChatSubmittedHandler(this.closePage.bind(this)); + + this.ChatAddressees = new ChatAddressees(); + this.ChatAddressees.registerSelectionChangeHandler( + command => { this.ChatSender.onSelectionChange(command); }); + + this.ChatMessageFormat = new ChatMessageFormat(); + this.ChatMessageFormat.registerMessageHandlers(ChatMessageFormatNetwork); + this.ChatMessageFormat.registerMessageHandlers(ChatMessageFormatSimulation); + this.ChatMessageFormatPlayer = new ChatMessageFormatPlayer(); + this.ChatMessageFormatPlayer.registerAddresseeTypes(this.ChatAddressees.AddresseeTypes); + this.ChatMessageFormat.registerMessageHandler("message", this.ChatMessageFormatPlayer); + + Engine.SetGlobalHotkey("chat", () => this.openPage()); + Engine.SetGlobalHotkey("teamchat", () => this.openPage(g_IsObserver ? "/observers" : "/allies")); + Engine.SetGlobalHotkey("privatechat", () => this.openPage()); + } + + /** + * Called by the owner whenever g_PlayerAssignments or g_Players changed. + */ + onUpdatePlayers() + { + this.ChatAddressees.updateChatAddressees(); + } + + 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 + " " + text; + + if (Engine.HasNetClient()) + Engine.SendNetworkChat(msg); + else + this.addMessage({ + "type": "message", + "guid": "local", + "text": msg + }); + } + + addMessage(msg) + { + let formatted = this.ChatMessageFormat.parseMessage(msg); + if (!formatted) + return; + + this.ChatOverlay.onChatMessage(msg, formatted); + this.ChatHistory.onChatMessage(msg, formatted); + + if (this.ChatWindow.isOpen() && this.ChatWindow.isExtended()) + this.ChatHistory.displayChatHistory(); + } +} Index: binaries/data/mods/public/gui/session/chat/ChatAddressees.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/session/chat/ChatAddressees.js @@ -0,0 +1,124 @@ +class ChatAddressees +{ + constructor() + { + this.selectionChangeHandlers = []; + + this.chatAddressee = Engine.GetGUIObjectByName("chatAddressee"); + this.chatAddressee.onSelectionChange = this.onSelectionChange.bind(this); + } + + getSelection() + { + return this.chatAddressee.list_data[this.chatAddressee.selected] || ""; + } + + select(command) + { + this.chatAddressee.selected = this.chatAddressee.list_data.indexOf(command); + } + + registerSelectionChangeHandler(handler) + { + this.selectionChangeHandlers.push(handler); + } + + onSelectionChange() + { + let selection = this.getSelection(); + for (let handler of this.selectionChangeHandlers) + handler(selection); + } + + updateChatAddressees() + { + // Remember previously selected item + let selectedName = this.getSelection(); + selectedName = selectedName.startsWith("/msg") && selectedName.substr(5); + + 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() + } +]; Index: binaries/data/mods/public/gui/session/chat/ChatHistory.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/session/chat/ChatHistory.js @@ -0,0 +1,117 @@ +class ChatHistory +{ + constructor() + { + /** + * All unparsed chat messages received since connect, including timestamp. + */ + this.chatMessages = []; + + this.chatHistoryText = Engine.GetGUIObjectByName("chatHistoryText"); + this.chatHistoryFilter = Engine.GetGUIObjectByName("chatHistoryFilter"); + + this.initChatHistoryFilter(); + } + + initChatHistoryFilter() + { + let filters = prepareForDropdown(this.Filters.filter(chatFilter => !chatFilter.hidden)); + this.chatHistoryFilter.onSelectionChange = this.displayChatHistory.bind(this); // TODO: Focus chat input + this.chatHistoryFilter.list = filters.text.map(text => translateWithContext("chat history filter", text)); + this.chatHistoryFilter.list_data = filters.key; + this.chatHistoryFilter.selected = 0; + } + + 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() + } +]; Index: binaries/data/mods/public/gui/session/chat/ChatMessageFormat.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/session/chat/ChatMessageFormat.js @@ -0,0 +1,49 @@ +class ChatMessageFormat +{ + constructor() + { + this.messageTypes = {}; + + this.registerMessageHandler("system", new ChatMessageFormat.System()); + } + + registerMessageHandler(type, handler) + { + if (!this.messageTypes[type]) + this.messageTypes[type] = []; + + this.messageTypes[type].push(handler); + } + + registerMessageHandlers(handlerTypes) + { + for (let type in handlerTypes) + this.registerMessageHandler(type, new handlerTypes[type]()); + } + + parseMessage(msg) + { + if (!this.messageTypes[msg.type]) + { + error("Unknown chat message type: " + uneval(msg)); + return undefined; + } + + for (let handler of this.messageTypes[msg.type]) + { + let txt = handler.parse(msg); + if (txt) + return txt; + } + + return undefined; + } +} + +ChatMessageFormat.System = class +{ + parse(msg) + { + return msg.txt; + } +}; Index: binaries/data/mods/public/gui/session/chat/ChatMessageFormatNetwork.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/session/chat/ChatMessageFormatNetwork.js @@ -0,0 +1,66 @@ +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) }); + } +}; Index: binaries/data/mods/public/gui/session/chat/ChatMessageFormatPlayer.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/session/chat/ChatMessageFormatPlayer.js @@ -0,0 +1,162 @@ +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.parseAddressedMessage(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. + */ + parseAddressedMessage(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") + } +}; Index: binaries/data/mods/public/gui/session/chat/ChatMessageFormatSimulation.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/session/chat/ChatMessageFormatSimulation.js @@ -0,0 +1,154 @@ +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.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 + }); + } +}; + +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)) + }); + } +}; + +/** + * 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) + }); + } +}; Index: binaries/data/mods/public/gui/session/chat/ChatOverlay.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/session/chat/ChatOverlay.js @@ -0,0 +1,66 @@ +class ChatOverlay +{ + constructor() + { + this.chatText = Engine.GetGUIObjectByName("chatText"); + + /** + * 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 = []; + } + + /** + * 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"); + } +} Index: binaries/data/mods/public/gui/session/chat/ChatSender.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/session/chat/ChatSender.js @@ -0,0 +1,60 @@ +class ChatSender +{ + 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); + + Engine.GetGUIObjectByName("sendChat").onPress = this.submitChatInput.bind(this); + } + + /** + * The functions registered using this function will be called sequentially + * when the user submits chat, until one of them returns true. + */ + registerChatSubmitHandler(submitChatHandler) + { + this.chatSubmitHandlers.push(submitChatHandler); + } + + /** + * The functions registered using this function will be called after the user submitted chat input. + */ + registerChatSubmittedHandler(submittedChatHandler) + { + this.chatSubmittedHandlers.push(submittedChatHandler); + } + + /** + * Called each time the addressee dropdown changes selection. + */ + onSelectionChange(command) + { + this.selectedCommand = command; + this.chatInput.focus(); + } + + 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) + this.chatSubmitHandlers.some(handler => handler(text, this.selectedCommand)); + + for (let handler of this.chatSubmittedHandlers) + handler(); + } +} Index: binaries/data/mods/public/gui/session/chat/ChatWindow.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/session/chat/ChatWindow.js @@ -0,0 +1,85 @@ +/** + * 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(); + } + + 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; + } +} Index: binaries/data/mods/public/gui/session/chat/chat_window.xml =================================================================== --- binaries/data/mods/public/gui/session/chat/chat_window.xml +++ 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(); Index: binaries/data/mods/public/gui/session/chat_window.xml =================================================================== --- binaries/data/mods/public/gui/session/chat_window.xml +++ binaries/data/mods/public/gui/session/chat_window.xml @@ -1,107 +0,0 @@ - - -