Rewrite lobby page to use class semantics, add more gamedetails labels, improve performance using batch processing and caching and gain possibility for game creation/player-join/leave events, refs #5387.

Game selection details features:
* Display victory conditions following their sending but missing display
following bffe917914, refs 7b0f6f530c.
* Display the host of the match and the game name in the selected game
details following 61261d14fc, refs D1666.
* Display mods if the mods differ (without having to attempt to join the
game prior) following eca956a513.

Performance features:
* Implement batch message processing in the XmppClient to rebuild GUI
objects only once when receiving backlog or returning from a match.
* Implement Game class to cache gamelist, filter and sorting values, as
they rarely change but are accessed often.
* Cache sprintf objects.

Security fixes:
* Add escapeText in lobby/ to avoid players breaking the lobby for every
participant, supersedes D720, comments by bb.
* Do not hide broadcasted unrecognized chat commands that mods used as
leaking private channels, fixes #5615.

Defect fixes:
* Fix XmppClient.cpp storing unused historic message types resulting in
memory waste and unintentional replay of for instance
disconnect/announcements messages following both e8dfde9ba6/D819 and
6bf74902a7/D2265, refs #3306.
* Fix XmppClient.cpp victoryCondition -> victoryConditions gamesetup.js
change from 6d54ab4c1f/D1240.
* Fix leaderboard/profile page cancel hotkey closing the lobby dialog as
well and removes cancel hotkey note from lobby_panels.xml from
960f2d7c31/D817 since the described issue was fixed by f9b529f2fb/D1701.
* Fix lobby playing menu sound in a running game after having closed the
lobby dialog following introduction in 960f2d7c31/D817.
* Fix GUI on nick change by updating g_Username.
* Update profile panel only with data matching the player requested.

Hack erasure:
* Object semantics make it cheap to add state and cache values, storing
literals in properties while removing globals, adding events while
decoupling components and gaining moddability.
* Erase comments and translation comments stating that this would be
IRC!!, supersedes D1136.
* Introduce Status chat message type to supersede "/special" chat
command + "isSpecial" property from bffe917914 (formerly g_specialKey
e6840f5fca) deluxe hack.
* Introduce System chat message type to supersede system errors
disguising as chat from a mock user called "system".

Code cleanups:
* Move code from XML to JS.
* Move size values from JS to XML, especially following 960f2d7c31/D817
and 7752219cef/D1051.
* Rename "user" to "player".
* Fix lobby/ eslint warnings, refs D2261.
* Remove message.nick emptiness check from 0940db3fc0/D835, since
XEP-0045 dictates that it is non-empty.
* Add translated string for deleted subjects.
* Add TODOs for some evident COList issues, refs #5638.

Differential Revision: https://code.wildfiregames.com/D2412
This was SVN commit r23172.
This commit is contained in:
elexis 2019-11-21 13:44:41 +00:00
parent d9e34fc88e
commit 2cccd9825d
61 changed files with 3407 additions and 1974 deletions

View File

@ -0,0 +1,64 @@
/**
* This is class manages the content of the leaderboardlist, i.e. the list of highest rated players.
*/
class LeaderboardList
{
constructor(xmppMessages)
{
this.selectionChangeHandlers = new Set();
this.leaderboardBox = Engine.GetGUIObjectByName("leaderboardBox");
this.leaderboardBox.onSelectionChange = this.onSelectionChange.bind(this);
let rebuild = this.rebuild.bind(this);
xmppMessages.registerXmppMessageHandler("game", "leaderboard", rebuild);
xmppMessages.registerXmppMessageHandler("system", "disconnected", rebuild);
this.rebuild();
}
registerSelectionChangeHandler(handler)
{
this.selectionChangeHandlers.add(handler);
}
onSelectionChange()
{
let playerName = this.selectedPlayer();
for (let handler of this.selectionChangeHandlers)
handler(playerName);
}
selectedPlayer()
{
return this.leaderboardBox.list[this.leaderboardBox.selected] || undefined;
}
/**
* Update the leaderboard from data cached in C++.
*/
rebuild()
{
// TODO: Display placeholder if the data is not available
let boardList = Engine.GetBoardList().sort(
(a, b) => b.rating - a.rating);
let list_name = [];
let list_rank = [];
let list_rating = [];
boardList.forEach((entry, i) => {
list_name.push(escapeText(entry.name));
list_rating.push(entry.rating);
list_rank.push(i + 1);
});
this.leaderboardBox.list_name = list_name;
this.leaderboardBox.list_rating = list_rating;
this.leaderboardBox.list_rank = list_rank;
this.leaderboardBox.list = list_name;
if (this.leaderboardBox.selected >= this.leaderboardBox.list.length)
this.leaderboardBox.selected = -1;
}
}

View File

@ -0,0 +1,52 @@
/**
* The leaderboard page allows the player to view the highest rated players and update that list.
*/
class LeaderboardPage
{
constructor(xmppMessages)
{
this.openPageHandlers = new Set();
this.closePageHandlers = new Set();
this.leaderboardList = new LeaderboardList(xmppMessages);
this.leaderboardPage = Engine.GetGUIObjectByName("leaderboardPage");
Engine.GetGUIObjectByName("leaderboardUpdateButton").onPress = this.onPressUpdate.bind(this);
Engine.GetGUIObjectByName("leaderboardPageBack").onPress = this.onPressClose.bind(this);
}
registerOpenPageHandler(handler)
{
this.openPageHandlers.add(handler);
}
registerClosePageHandler(handler)
{
this.closePageHandlers.add(handler);
}
openPage()
{
this.leaderboardPage.hidden = false;
Engine.SetGlobalHotkey("cancel", this.onPressClose.bind(this));
Engine.SendGetBoardList();
let playerName = this.leaderboardList.selectedPlayer();
for (let handler of this.openPageHandlers)
handler(playerName);
}
onPressUpdate()
{
Engine.SendGetBoardList();
}
onPressClose()
{
this.leaderboardPage.hidden = true;
for (let handler of this.closePageHandlers)
handler();
}
}

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<object name="leaderboardPage" hidden="true">
<!-- Translucent black background -->
<object type="image" z="100" sprite="ModernFade"/>
<object type="image" style="ModernDialog" size="50%-224 50%-160 50%+224 50%+160" z="101">
<object style="ModernLabelText" type="text" size="50%-128 -18 50%+128 14">
<translatableAttribute id="caption">Leaderboard</translatableAttribute>
</object>
<object name="leaderboardBox" style="ModernList" type="olist" size="19 19 100%-19 100%-62">
<column id="rank" color="255 255 255" width="15%">
<translatableAttribute id="heading">Rank</translatableAttribute>
</column>
<column id="name" color="255 255 255" width="55%">
<translatableAttribute id="heading">Name</translatableAttribute>
</column>
<column id="rating" color="255 255 255" width="30%">
<translatableAttribute id="heading">Rating</translatableAttribute>
</column>
</object>
<object type="button" name="leaderboardPageBack" style="ModernButtonRed" size="50%-133 100%-45 50%-5 100%-17">
<translatableAttribute id="caption">Back</translatableAttribute>
</object>
<object type="button" name="leaderboardUpdateButton" style="ModernButtonRed" size="50%+5 100%-45 50%+133 100%-17">
<translatableAttribute id="caption">Update</translatableAttribute>
</object>
</object>
</object>

View File

@ -0,0 +1,25 @@
/**
* This class owns the page handlers.
*/
class Lobby
{
constructor(dialog)
{
this.xmppMessages = new XmppMessages();
this.profilePage = new ProfilePage(this.xmppMessages);
this.leaderboardPage = new LeaderboardPage(this.xmppMessages);
this.lobbyPage = new LobbyPage(dialog, this.xmppMessages, this.leaderboardPage, this.profilePage);
this.xmppMessages.processHistoricMessages();
if (Engine.LobbyGetPlayerPresence(g_Nickname) != "available")
Engine.LobbySetPlayerPresence("available");
if (!dialog)
{
initMusic();
global.music.setState(global.music.states.MENU);
}
}
}

View File

@ -0,0 +1,20 @@
/**
* This class informs clients of the server if an announcement had been broadcasted.
*/
class AnnouncementHandler
{
constructor(xmppMessages)
{
xmppMessages.registerXmppMessageHandler("chat", "private-message", this.onPrivateMessage.bind(this));
}
onPrivateMessage(message)
{
// Announcements and the Message of the Day are sent by the server directly
if (!message.from)
messageBox(
400, 250,
message.text.trim(),
translate("Notice"));
}
}

View File

@ -0,0 +1,70 @@
/**
* This class manages the button that enables the player to add or remove buddies.
*/
class BuddyButton
{
constructor(xmppMessages)
{
this.buddyChangedHandlers = new Set();
this.playerName = undefined;
this.toggleBuddyButton = Engine.GetGUIObjectByName("toggleBuddyButton");
this.toggleBuddyButton.onPress = this.onPress.bind(this);
let rebuild = this.rebuild.bind(this);
xmppMessages.registerXmppMessageHandler("system", "connected", rebuild);
xmppMessages.registerXmppMessageHandler("system", "disconnected", rebuild);
this.rebuild();
}
registerBuddyChangeHandler(handler)
{
this.buddyChangedHandlers.add(handler);
}
onPlayerSelectionChange(playerName)
{
this.playerName = playerName;
this.rebuild();
}
rebuild()
{
this.toggleBuddyButton.caption =
g_Buddies.indexOf(this.playerName) != -1 ?
this.UnmarkString :
this.MarkString;
this.toggleBuddyButton.enabled = Engine.IsXmppClientConnected() && !!this.playerName && this.playerName != g_Nickname;
}
/**
* Toggle the buddy state of the selected player.
*/
onPress()
{
if (!this.playerName || this.playerName == g_Nickname || this.playerName.indexOf(g_BuddyListDelimiter) != -1)
return;
let index = g_Buddies.indexOf(this.playerName);
if (index != -1)
g_Buddies.splice(index, 1);
else
g_Buddies.push(this.playerName);
Engine.ConfigDB_CreateAndWriteValueToFile(
"user",
"lobby.buddies",
g_Buddies.filter(nick => nick).join(g_BuddyListDelimiter) || g_BuddyListDelimiter,
"config/user.cfg");
this.rebuild();
for (let handler of this.buddyChangedHandlers)
handler();
}
}
BuddyButton.prototype.MarkString = translate("Mark as Buddy");
BuddyButton.prototype.UnmarkString = translate("Unmark as Buddy");

View File

@ -0,0 +1,32 @@
/**
* This class manages the button that enables the player to configure the start a new hosted multiplayer match.
*/
class HostButton
{
constructor(dialog, xmppMessages)
{
this.hostButton = Engine.GetGUIObjectByName("hostButton");
this.hostButton.onPress = this.onPress.bind(this);
this.hostButton.caption = translate("Host Game");
this.hostButton.hidden = dialog;
let onConnectionStatusChange = this.onConnectionStatusChange.bind(this);
xmppMessages.registerXmppMessageHandler("system", "connected", onConnectionStatusChange);
xmppMessages.registerXmppMessageHandler("system", "disconnected", onConnectionStatusChange);
this.onConnectionStatusChange();
}
onConnectionStatusChange()
{
this.hostButton.enabled = Engine.IsXmppClientConnected();
}
onPress()
{
Engine.PushGuiPage("page_gamesetup_mp.xml", {
"multiplayerGameType": "host",
"name": g_Nickname,
"rating": Engine.LobbyGetPlayerRating(g_Nickname)
});
}
}

View File

@ -0,0 +1,130 @@
/**
* This class manages the button that enables the player to join a lobby game hosted by a remote player.
*/
class JoinButton
{
constructor(dialog, gameList)
{
this.gameList = gameList;
this.joinButton = Engine.GetGUIObjectByName("joinButton");
this.joinButton.caption = this.Caption;
this.joinButton.hidden = dialog;
if (!dialog)
this.joinButton.onPress = this.onPress.bind(this);
gameList.gamesBox.onMouseLeftDoubleClickItem = this.onPress.bind(this);
gameList.registerSelectionChangeHandler(this.onSelectedGameChange.bind(this, dialog));
}
onSelectedGameChange(dialog, selectedGame)
{
this.joinButton.hidden = dialog || !selectedGame;
}
/**
* Immediately rejoin and join gamesetups. Otherwise confirm late-observer join attempt.
*/
onPress()
{
let game = this.gameList.selectedGame();
if (!game)
return;
let rating = this.getRejoinRating(game);
let playername = rating ? g_Nickname + " (" + rating + ")" : g_Nickname;
if (!game.isCompatible)
messageBox(
400, 200,
translate("Your active mods do not match the mods of this game.") + "\n\n" +
comparedModsString(game.mods, Engine.GetEngineInfo().mods) + "\n\n" +
translate("Do you want to switch to the mod selection page?"),
translate("Incompatible mods"),
[translate("No"), translate("Yes")],
[null, this.openModSelectionPage.bind(this)]
);
else if (game.stanza.state == "init" || game.players.some(player => player.Name == playername))
this.joinSelectedGame();
else
messageBox(
400, 200,
translate("The game has already started. Do you want to join as observer?"),
translate("Confirmation"),
[translate("No"), translate("Yes")],
[null, this.joinSelectedGame.bind(this)]);
}
/**
* Attempt to join the selected game without asking for confirmation.
*/
joinSelectedGame()
{
if (this.joinButton.hidden)
return;
let game = this.gameList.selectedGame();
if (!game)
return;
let ip;
let port;
let stanza = game.stanza;
if (stanza.stunIP)
{
ip = stanza.stunIP;
port = stanza.stunPort;
}
else
{
ip = stanza.ip;
port = stanza.port;
}
if (ip.split('.').length != 4)
{
messageBox(
400, 250,
sprintf(
translate("This game's address '%(ip)s' does not appear to be valid."),
{ "ip": escapeText(stanza.ip) }),
translate("Error"));
return;
}
Engine.PushGuiPage("page_gamesetup_mp.xml", {
"multiplayerGameType": "join",
"ip": ip,
"port": port,
"name": g_Nickname,
"rating": this.getRejoinRating(stanza),
"useSTUN": !!stanza.stunIP,
"hostJID": stanza.hostUsername + "@" + Engine.ConfigDB_GetValue("user", "lobby.server") + "/0ad"
});
}
openModSelectionPage()
{
Engine.StopXmppClient();
Engine.SwitchGuiPage("page_modmod.xml", {
"cancelbutton": true
});
}
/**
* Rejoin games with the original playername, even if the rating changed meanwhile.
*/
getRejoinRating(game)
{
for (let player of game.players)
{
let playerNickRating = splitRatingFromNick(player.Name);
if (playerNickRating.nick == g_Nickname)
return playerNickRating.rating;
}
return Engine.LobbyGetPlayerRating(g_Nickname);
}
}
// Translation: Join the game currently selected in the list.
JoinButton.prototype.Caption = translate("Join Game");

View File

@ -0,0 +1,22 @@
/**
* This class deals with the button that opens the leaderboard page.
*/
class LeaderboardButton
{
constructor(xmppMessages, leaderboardPage)
{
this.leaderboardButton = Engine.GetGUIObjectByName("leaderboardButton");
this.leaderboardButton.caption = translate("Leaderboard");
this.leaderboardButton.onPress = leaderboardPage.openPage.bind(leaderboardPage);
let onConnectionStatusChange = this.onConnectionStatusChange.bind(this);
xmppMessages.registerXmppMessageHandler("system", "connected", onConnectionStatusChange);
xmppMessages.registerXmppMessageHandler("system", "disconnected", onConnectionStatusChange);
this.onConnectionStatusChange();
}
onConnectionStatusChange()
{
this.leaderboardButton.enabled = Engine.IsXmppClientConnected();
}
}

View File

@ -0,0 +1,22 @@
/**
* This class deals with the button that opens the profile view page.
*/
class ProfileButton
{
constructor(xmppMessages, profilePage)
{
this.profileButton = Engine.GetGUIObjectByName("profileButton");
this.profileButton.caption = translate("Player Profile Lookup");
this.profileButton.onPress = profilePage.openPage.bind(profilePage, false);
let onConnectionStatusChange = this.onConnectionStatusChange.bind(this);
xmppMessages.registerXmppMessageHandler("system", "connected", onConnectionStatusChange);
xmppMessages.registerXmppMessageHandler("system", "disconnected", onConnectionStatusChange);
this.onConnectionStatusChange();
}
onConnectionStatusChange()
{
this.profileButton.enabled = Engine.IsXmppClientConnected();
}
}

View File

@ -0,0 +1,40 @@
/**
* This class manages the button that allows the player to close the lobby page.
*/
class QuitButton
{
constructor(dialog, leaderboardPage, profilePage)
{
let closeDialog = this.closeDialog.bind(this);
let returnToMainMenu = this.returnToMainMenu.bind(this);
let onPress = dialog ? closeDialog : returnToMainMenu;
let leaveButton = Engine.GetGUIObjectByName("leaveButton");
leaveButton.onPress = onPress;
leaveButton.caption = dialog ?
translateWithContext("previous page", "Back") :
translateWithContext("previous page", "Main Menu");
if (dialog)
{
Engine.SetGlobalHotkey("lobby", onPress);
Engine.SetGlobalHotkey("cancel", onPress);
let cancelHotkey = Engine.SetGlobalHotkey.bind(Engine, "cancel", onPress);
leaderboardPage.registerClosePageHandler(cancelHotkey);
profilePage.registerClosePageHandler(cancelHotkey);
}
}
closeDialog()
{
Engine.LobbySetPlayerPresence("playing");
Engine.PopGuiPage();
}
returnToMainMenu()
{
Engine.StopXmppClient();
Engine.SwitchGuiPage("page_pregame.xml");
}
}

View File

@ -0,0 +1,166 @@
/**
* The purpose of this class is to test if a given textual input of the current player
* is not a chat message to be sent but a command to be performed locally or on the
* server, and if so perform it.
*/
class ChatCommandHandler
{
constructor(chatMessagesPanel, systemMessageFormat)
{
this.chatMessagesPanel = chatMessagesPanel;
this.systemMessageFormat = systemMessageFormat;
}
/**
* @returns {boolean} true if the input was successfully parsed as a chat command.
*/
handleChatCommand(text)
{
if (!text.startsWith('/'))
return false;
let index = text.indexOf(" ");
let command = text.substr(1, index == -1 ? undefined : index - 1);
let args = index == -1 ? "" : text.substr(index + 1);
let commandObj = this.ChatCommands[command] || undefined;
if (!commandObj)
{
this.chatMessagesPanel.addText(
Date.now() / 1000,
this.systemMessageFormat.format(
sprintf(translate("The command '%(cmd)s' is not supported."), {
"cmd": setStringTags(escapeText(command), this.ChatCommandTags)
})));
this.chatMessagesPanel.flushMessages();
return true;
}
if (commandObj.moderatorOnly && Engine.LobbyGetPlayerRole(g_Nickname) != "moderator")
{
this.chatMessagesPanel.addText(
Date.now() / 1000,
this.systemMessageFormat.format(
sprintf(translate("The command '%(cmd)s' is restricted to moderators."), {
"cmd": setStringTags(escapeText(command), this.ChatCommandTags)
})));
this.chatMessagesPanel.flushMessages();
return true;
}
let handler = commandObj && commandObj.handler || undefined;
if (!handler)
return false;
return handler.call(this, args);
}
argumentCount(commandName, args)
{
if (args.trim())
return false;
this.chatMessagesPanel.addText(
Date.now() / 1000,
this.systemMessageFormat.format(
sprintf(translate("The command '%(cmd)s' requires at least one argument."), {
"cmd": setStringTags(commandName, this.ChatCommandTags)
})));
this.chatMessagesPanel.flushMessages();
return true;
}
}
/**
* Color to highlight chat commands in the explanation.
*/
ChatCommandHandler.prototype.ChatCommandTags = {
"color": "200 200 255"
};
/**
* Commands that can be entered by clients via chat input.
* A handler returns true if the user input should be sent as a chat message.
*/
ChatCommandHandler.prototype.ChatCommands = {
"away": {
"description": translate("Set your state to 'Away'."),
"handler": function(args) {
Engine.LobbySetPlayerPresence("away");
return true;
}
},
"back": {
"description": translate("Set your state to 'Online'."),
"handler": function(args) {
Engine.LobbySetPlayerPresence("available");
return true;
}
},
"kick": {
"description": translate("Kick a specified user from the lobby. Usage: /kick nick reason"),
"handler": function(args) {
let index = args.indexOf(" ");
if (index == -1)
Engine.LobbyKick(args, "");
else
Engine.LobbyKick(args.substr(0, index), args.substr(index + 1));
return true;
},
"moderatorOnly": true
},
"ban": {
"description": translate("Ban a specified user from the lobby. Usage: /ban nick reason"),
"handler": function(args) {
let index = args.indexOf(" ");
if (index == -1)
Engine.LobbyBan(args, "");
else
Engine.LobbyBan(args.substr(0, index), args.substr(index + 1));
return true;
},
"moderatorOnly": true
},
"help": {
"description": translate("Show this help."),
"handler": function(args) {
let isModerator = Engine.LobbyGetPlayerRole(g_Nickname) == "moderator";
let txt = translate("Chat commands:");
for (let command in this.ChatCommands)
if (!this.ChatCommands[command].moderatorOnly || isModerator)
// Translation: Chat command help format
txt += "\n" + sprintf(translate("%(command)s - %(description)s"), {
"command": setStringTags(command, this.ChatCommandTags),
"description": this.ChatCommands[command].description
});
this.chatMessagesPanel.addText(
Date.now() / 1000,
this.systemMessageFormat.format(txt));
this.chatMessagesPanel.flushMessages();
return true;
}
},
"me": {
"description": translate("Send a chat message about yourself. Example: /me goes swimming."),
"handler": function(args) {
// Translation: Chat command
return this.argumentCount(translate("/me"), args);
}
},
"say": {
"description": translate("Send text as a chat message (even if it starts with slash). Example: /say /help is a great command."),
"handler": function(args) {
// Translation: Chat command
return this.argumentCount(translate("/say"), args);
}
},
"clear": {
"description": translate("Clear all chat scrollback."),
"handler": function(args) {
this.chatMessagesPanel.clearChatMessages();
return true;
}
}
};

View File

@ -0,0 +1,52 @@
/**
* The purpose of this class is to process the chat input of the local player and
* either submit the input as chat or perform it as a local or remote command.
*/
class ChatInputPanel
{
constructor(xmppMessages, chatMessagesPanel, systemMessageFormat)
{
this.chatCommandHandler = new ChatCommandHandler(chatMessagesPanel, systemMessageFormat);
this.chatSubmit = Engine.GetGUIObjectByName("chatSubmit");
this.chatSubmit.onPress = this.submitChatInput.bind(this);
this.chatInput = Engine.GetGUIObjectByName("chatInput");
this.chatInput.onPress = this.submitChatInput.bind(this);
this.chatInput.onTab = this.autocomplete.bind(this);
this.chatInput.tooltip = colorizeAutocompleteHotkey();
let update = this.update.bind(this);
xmppMessages.registerXmppMessageHandler("system", "connected", update);
xmppMessages.registerXmppMessageHandler("system", "disconnected", update);
xmppMessages.registerXmppMessageHandler("chat", "role", update);
this.update();
}
update()
{
let hidden = !Engine.IsXmppClientConnected() || Engine.LobbyGetPlayerRole(g_Nickname) == "visitor";
this.chatInput.hidden = hidden;
this.chatSubmit.hidden = hidden;
}
submitChatInput()
{
let text = this.chatInput.caption;
if (!text.length)
return;
if (!this.chatCommandHandler.handleChatCommand(text))
Engine.LobbySendMessage(text);
this.chatInput.caption = "";
}
autocomplete()
{
autoCompleteText(
this.chatInput,
Engine.GetPlayerList().map(player => player.name));
}
}

View File

@ -0,0 +1,26 @@
/**
* This is the class (and only class) that formats textual messages submitted by chat participants.
*/
ChatMessageEvents.PlayerChat = class
{
constructor(xmppMessages, chatMessagesPanel)
{
this.chatMessagesPanel = chatMessagesPanel;
this.chatMessageFormat = new ChatMessageFormat();
xmppMessages.registerXmppMessageHandler("chat", "room-message", this.onRoomMessage.bind(this));
xmppMessages.registerXmppMessageHandler("chat", "private-message", this.onPrivateMessage.bind(this));
}
onRoomMessage(message)
{
this.chatMessagesPanel.addText(message.time, this.chatMessageFormat.format(message));
}
onPrivateMessage(message)
{
// We intend to not support private messages between users
if (!message.from || Engine.LobbyGetPlayerRole(message.from) == "moderator")
// some XMPP clients send trailing whitespace
this.chatMessagesPanel.addText(message.time, this.chatMessageFormat.format(message));
}
};

View File

@ -0,0 +1,69 @@
/**
* Formats a chat message sent by a player (i.e. not a chat notification),
* accounting for chat format commands such as /me or /say and private messages.
*
* Plays an acoustic notification if the playername was mentioned
*/
class ChatMessageFormat
{
constructor()
{
this.chatMessageFormatMe = new ChatMessageFormatMe();
this.chatMessageFormatSay = new ChatMessageFormatSay();
this.chatMessagePrivateWrapper = new ChatMessagePrivateWrapper();
}
/**
* Message properties: from, text, historic, optionally private
*/
format(message)
{
let text = escapeText(message.text);
if (g_Nickname != message.from)
{
// Highlight nicknames, assume they do not contain escapaped characters
text = text.replace(g_Nickname, PlayerColor.ColorPlayerName(g_Nickname));
// Notify local player
if (!message.historic && text.toLowerCase().indexOf(g_Nickname.toLowerCase()) != -1)
soundNotification("nick");
}
let sender = PlayerColor.ColorPlayerName(message.from, undefined, Engine.LobbyGetPlayerRole(message.from));
// Handle chat format commands
let formattedMessage;
let index = text.indexOf(" ");
if (text.startsWith("/") && index != -1)
{
let command = text.substr(1, index - 1);
let commandText = text.substr(index + 1);
switch (command)
{
case "me":
{
formattedMessage = this.chatMessageFormatMe.format(sender, commandText);
break;
}
case "say":
{
formattedMessage = this.chatMessageFormatSay.format(sender, commandText);
break;
}
default:
{
formattedMessage = this.chatMessageFormatSay.format(sender, text);
break;
}
}
}
else
formattedMessage = this.chatMessageFormatSay.format(sender, text);
if (message.level == "private-message")
formattedMessage = this.chatMessagePrivateWrapper.format(formattedMessage);
return formattedMessage;
}
}

View File

@ -0,0 +1,31 @@
/**
* This class formats a chat message that was sent using the /me format command.
* For example "/me goes away".
*/
class ChatMessageFormatMe
{
constructor()
{
this.args = {};
}
/**
* Sender is formatted, escapeText is the responsibility of the caller.
*/
format(sender, text)
{
this.args.sender = setStringTags(sender, this.SenderTags);
this.args.message = text;
return sprintf(this.Format, this.args);
}
}
// Translation: Chat message issued using the ‘/me’ command.
ChatMessageFormatMe.prototype.Format = translate("* %(sender)s %(message)s");
/**
* Used for highlighting the sender of chat messages.
*/
ChatMessageFormatMe.prototype.SenderTags = {
"font": "sans-bold-13"
};

View File

@ -0,0 +1,38 @@
/**
* This class formats a chat message that was not formatted with any commands.
* The nickname and the message content will be assumed to be player input, thus escaped,
* meaning that one cannot use colorized messages here.
*/
class ChatMessageFormatSay
{
constructor()
{
this.senderArgs = {};
this.messageArgs = {};
}
/**
* Sender is formatted, escapeText is the responsibility of the caller.
*/
format(sender, text)
{
this.senderArgs.sender = sender;
this.messageArgs.message = text;
this.messageArgs.sender = setStringTags(
sprintf(this.ChatSenderFormat, this.senderArgs),
this.SenderTags);
return sprintf(this.ChatMessageFormat, this.messageArgs);
}
}
ChatMessageFormatSay.prototype.ChatSenderFormat = translate("<%(sender)s>");
ChatMessageFormatSay.prototype.ChatMessageFormat = translate("%(sender)s %(message)s");
/**
* Used for highlighting the sender of chat messages.
*/
ChatMessageFormatSay.prototype.SenderTags = {
"font": "sans-bold-13"
};

View File

@ -0,0 +1,32 @@
/**
* This class adds an indication that the chat message was a private message to the given text.
*/
class ChatMessagePrivateWrapper
{
constructor()
{
this.args = {
"private": setStringTags(this.PrivateFormat, this.PrivateMessageTags)
};
}
/**
* Text is formatted, escapeText is the responsibility of the caller.
*/
format(text)
{
this.args.message = text;
return sprintf(this.PrivateMessageFormat, this.args);
}
}
ChatMessagePrivateWrapper.prototype.PrivateFormat = translate("Private");
ChatMessagePrivateWrapper.prototype.PrivateMessageFormat = translate("(%(private)s) %(message)s");
/**
* Color for private messages in the chat.
*/
ChatMessagePrivateWrapper.prototype.PrivateMessageTags = {
"color": "0 150 0"
};

View File

@ -0,0 +1,52 @@
/**
* This class stores and displays the chat history since the login and
* displays timestamps if enabled.
*/
class ChatMessagesPanel
{
constructor(xmppMessages)
{
this.xmppMessages = xmppMessages;
this.chatText = Engine.GetGUIObjectByName("chatText");
this.chatHistory = "";
if (Engine.ConfigDB_GetValue("user", "chat.timestamp") == "true")
this.timestampWrapper = new TimestampWrapper();
this.hasUpdate = false;
this.flushEvent = this.flushMessages.bind(this);
}
addText(timestamp, text)
{
if (this.timestampWrapper)
text = this.timestampWrapper.format(timestamp, text);
this.chatHistory += this.chatHistory ? "\n" + text : text;
if (!this.hasUpdate)
{
this.hasUpdate = true;
// Most xmpp messages are not chat messages, hence
// only subscribe the event handler when relevant to improve performance.
this.xmppMessages.registerMessageBatchProcessedHandler(this.flushEvent);
}
}
flushMessages()
{
if (this.hasUpdate)
{
this.chatText.caption = this.chatHistory;
this.hasUpdate = false;
this.xmppMessages.unregisterMessageBatchProcessedHandler(this.flushEvent);
}
}
clearChatMessages()
{
this.chatHistory = "";
this.chatText.caption = "";
}
}

View File

@ -0,0 +1,26 @@
/**
* Properties of this prototype are classes that subscribe to one or more events and
* construct a formatted chat message to be displayed on that event.
*
* Important: Apply escapeText on player provided input to avoid players breaking the game for everybody.
*/
class ChatMessageEvents
{
}
class ChatPanel
{
constructor(xmppMessages)
{
this.systemMessageFormat = new SystemMessageFormat();
this.statusMessageFormat = new StatusMessageFormat();
this.chatMessagesPanel = new ChatMessagesPanel(xmppMessages);
this.chatInputPanel = new ChatInputPanel(xmppMessages, this.chatMessagesPanel, this.systemMessageFormat);
this.chatMessageEvents = {};
for (let name in ChatMessageEvents)
this.chatMessageEvents[name] = new ChatMessageEvents[name](
xmppMessages, this.chatMessagesPanel, this.statusMessageFormat, this.systemMessageFormat);
}
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<object name="chatPanel" type="image" sprite="ModernDarkBoxGold">
<object name="chatText" size="0 0 100% 100%-28" type="text" style="ChatPanel" font="sans-13"/>
<object name="chatInput" size="0 100%-26 100%-72 100%-4" type="input" style="ModernInput" font="sans-13"/>
<object name="chatSubmit" size="100%-72 100%-26 100%-4 100%-4" type="button" style="StoneButton">
<translatableAttribute id="caption">Send</translatableAttribute>
</object>
</object>

View File

@ -0,0 +1,153 @@
/**
* @file The classes in this file trigger notifications about occurrences in the multi-user
* chat room that are not chat messages, nor SystemMessages.
*/
ChatMessageEvents.ClientEvents = class
{
constructor(xmppMessages, chatMessagesPanel, statusMessageFormat)
{
this.chatMessagesPanel = chatMessagesPanel;
this.statusMessageFormat = statusMessageFormat;
this.kickStrings = new KickStrings();
this.nickArgs = {};
xmppMessages.registerXmppMessageHandler("chat", "join", this.onClientJoin.bind(this));
xmppMessages.registerXmppMessageHandler("chat", "leave", this.onClientLeave.bind(this));
xmppMessages.registerXmppMessageHandler("chat", "kicked", this.onClientKicked.bind(this, false));
xmppMessages.registerXmppMessageHandler("chat", "banned", this.onClientKicked.bind(this, true));
}
onClientJoin(message)
{
this.nickArgs.nick = escapeText(message.nick);
this.chatMessagesPanel.addText(
message.time,
this.statusMessageFormat.format(sprintf(this.FormatJoin, this.nickArgs)));
}
onClientLeave(message)
{
this.nickArgs.nick = escapeText(message.nick);
this.chatMessagesPanel.addText(
message.time,
this.statusMessageFormat.format(sprintf(this.FormatLeave, this.nickArgs)));
}
onClientKicked(banned, message)
{
// If the local player had been kicked, that is logged more vividly than a neutral status message
if (message.nick != g_Nickname)
this.chatMessagesPanel.addText(
message.time,
this.statusMessageFormat.format(this.kickStrings.get(banned, message)));
}
};
ChatMessageEvents.ClientEvents.prototype.FormatJoin = translate("%(nick)s has joined.");
ChatMessageEvents.ClientEvents.prototype.FormatLeave = translate("%(nick)s has left.");
ChatMessageEvents.Nick = class
{
constructor(xmppMessages, chatMessagesPanel, statusMessageFormat)
{
this.chatMessagesPanel = chatMessagesPanel;
this.statusMessageFormat = statusMessageFormat;
this.args = {};
xmppMessages.registerXmppMessageHandler("chat", "nick", this.onNickChange.bind(this));
}
onNickChange(message)
{
this.args.oldnick = escapeText(message.oldnick);
this.args.newnick = escapeText(message.newnick);
this.chatMessagesPanel.addText(
message.time,
this.statusMessageFormat.format(sprintf(this.Format, this.args)));
}
};
ChatMessageEvents.Nick.prototype.Format = translate("%(oldnick)s is now known as %(newnick)s.");
ChatMessageEvents.Role = class
{
constructor(xmppMessages, chatMessagesPanel, statusMessageFormat)
{
this.chatMessagesPanel = chatMessagesPanel;
this.statusMessageFormat = statusMessageFormat;
this.args = {};
xmppMessages.registerXmppMessageHandler("chat", "role", this.onRoleChange.bind(this));
}
onRoleChange(message)
{
let roleType = this.RoleStrings.find(type =>
type.newrole == message.newrole &&
(!type.oldrole || type.oldrole == message.oldrole));
let txt;
if (message.nick == g_Nickname)
txt = roleType.you;
else
{
this.args.nick = escapeText(message.nick);
txt = sprintf(roleType.nick, this.args);
}
this.chatMessagesPanel.addText(
message.time,
this.statusMessageFormat.format(txt));
}
};
ChatMessageEvents.Role.prototype.RoleStrings =
[
{
"newrole": "visitor",
"you": translate("You have been muted."),
"nick": translate("%(nick)s has been muted.")
},
{
"newrole": "moderator",
"you": translate("You are now a moderator."),
"nick": translate("%(nick)s is now a moderator.")
},
{
"newrole": "participant",
"oldrole": "visitor",
"you": translate("You have been unmuted."),
"nick": translate("%(nick)s has been unmuted.")
},
{
"newrole": "participant",
"oldrole": "moderator",
"you": translate("You are not a moderator anymore."),
"nick": translate("%(nick)s is not a moderator anymore.")
}
];
ChatMessageEvents.Subject = class
{
constructor(xmppMessages, chatMessagesPanel, statusMessageFormat)
{
this.chatMessagesPanel = chatMessagesPanel;
this.statusMessageFormat = statusMessageFormat;
this.args = {};
xmppMessages.registerXmppMessageHandler("chat", "subject", this.onSubjectChange.bind(this));
}
onSubjectChange(message)
{
this.args.nick = escapeText(message.nick);
let subject = message.subject.trim();
this.chatMessagesPanel.addText(
message.time,
this.statusMessageFormat.format(
subject ?
sprintf(this.FormatChange, this.args) + "\n" + subject :
sprintf(this.FormatDelete, this.args)));
}
};
ChatMessageEvents.Subject.prototype.FormatChange = translate("%(nick)s changed the lobby subject to:");
ChatMessageEvents.Subject.prototype.FormatDelete = translate("%(nick)s deleted the lobby subject.");

View File

@ -0,0 +1,29 @@
/**
* Status messages are textual event notifications triggered by multi-user chat room actions.
*/
class StatusMessageFormat
{
constructor()
{
this.args = {};
}
/**
* escapeText is the responsibility of the caller.
*/
format(text)
{
this.args.message = text;
return setStringTags(
sprintf(this.MessageFormat, this.args),
this.MessageTags);
}
}
StatusMessageFormat.prototype.MessageFormat =
// Translation: Chat status message
translate("== %(message)s");
StatusMessageFormat.prototype.MessageTags = {
"font": "sans-bold-13"
};

View File

@ -0,0 +1,56 @@
/**
* System messages are highlighted chat notifications that concern the current player.
*/
ChatMessageEvents.System = class
{
constructor(xmppMessages, chatMessagesPanel, statusMessageFormat, systemMessageFormat)
{
this.chatMessagesPanel = chatMessagesPanel;
this.systemMessageFormat = systemMessageFormat;
this.kickStrings = new KickStrings();
xmppMessages.registerXmppMessageHandler("system", "connected", this.onConnected.bind(this));
xmppMessages.registerXmppMessageHandler("system", "disconnected", this.onDisconnected.bind(this));
xmppMessages.registerXmppMessageHandler("system", "error", this.onSystemError.bind(this));
xmppMessages.registerXmppMessageHandler("chat", "kicked", this.onClientKicked.bind(this, false));
xmppMessages.registerXmppMessageHandler("chat", "banned", this.onClientKicked.bind(this, true));
}
// TODO: XmppClient StanzaErrorServiceUnavailable is thrown if the ratings bot is not serving
// This should be caught more transparently than an unrelatable "Service unavailable" system error chat message
onSystemError(message)
{
this.chatMessagesPanel.addText(
message.time,
this.systemMessageFormat.format(
escapeText(message.text)));
}
onConnected(message)
{
this.chatMessagesPanel.addText(
message.time,
this.systemMessageFormat.format(this.ConnectedCaption));
}
onDisconnected(message)
{
this.chatMessagesPanel.addText(
message.time,
this.systemMessageFormat.format(
this.DisconnectedCaption + " " +
escapeText(message.reason + " " + message.certificate_status)));
}
onClientKicked(banned, message)
{
if (message.nick == g_Nickname)
this.chatMessagesPanel.addText(
message.time,
this.systemMessageFormat.format(
this.kickStrings.get(banned, message)));
}
};
ChatMessageEvents.System.prototype.ConnectedCaption = translate("Connected.");
ChatMessageEvents.System.prototype.DisconnectedCaption = translate("Disconnected.");

View File

@ -0,0 +1,36 @@
/**
* Status messages are textual event notifications triggered by local events.
* The messages may be colorized, hence the caller needs to apply escapeText on player input.
*/
class SystemMessageFormat
{
constructor()
{
this.args = {
"system": setStringTags(this.System, this.SystemTags)
};
}
format(text)
{
this.args.message = text;
return setStringTags(
sprintf(this.MessageFormat, this.args),
this.MessageTags);
}
}
SystemMessageFormat.prototype.System =
// Translation: Caption for system notifications shown in the chat panel
translate("System:");
SystemMessageFormat.prototype.SystemTags = {
"color": "150 0 0"
};
SystemMessageFormat.prototype.MessageFormat =
translate("=== %(system)s %(message)s");
SystemMessageFormat.prototype.MessageTags = {
"font": "sans-bold-13"
};

View File

@ -0,0 +1,33 @@
/**
* This class wraps a string with a timestamp dating to when the message was sent.
*/
class TimestampWrapper
{
constructor()
{
this.timeArgs = {};
this.timestampArgs = {};
}
format(timestamp, text)
{
this.timeArgs.time =
Engine.FormatMillisecondsIntoDateStringLocal(timestamp * 1000, this.TimeFormat);
this.timestampArgs.time = sprintf(this.TimestampFormat, this.timeArgs);
this.timestampArgs.message = text;
return sprintf(this.TimestampedMessageFormat, this.timestampArgs);
}
}
// Translation: Chat message format when there is a time prefix.
TimestampWrapper.prototype.TimestampedMessageFormat = translate("%(time)s %(message)s");
// Translation: Time prefix as shown in the multiplayer lobby (when you enable it in the options page).
TimestampWrapper.prototype.TimestampFormat = translate("\\[%(time)s]");
// Translation: Time as shown in the multiplayer lobby (when you enable it in the options page).
// For a list of symbols that you can use, see:
// https://sites.google.com/site/icuprojectuserguide/formatparse/datetime?pli=1#TOC-Date-Field-Symbol-Table
TimestampWrapper.prototype.TimeFormat = translate("HH:mm");

View File

@ -0,0 +1,74 @@
/**
* This class will ask the player to rejoin the lobby after having been disconnected.
*/
class ConnectionHandler
{
constructor(xmppMessages)
{
// Whether the current player has been kicked or banned
this.kicked = false;
// Avoid stacking of multiple dialog boxes
this.askingReconnect = false;
xmppMessages.registerXmppMessageHandler("chat", "leave", this.onClientLeave.bind(this));
xmppMessages.registerXmppMessageHandler("chat", "kicked", this.onClientKicked.bind(this, false));
xmppMessages.registerXmppMessageHandler("system", "disconnected", this.askReconnect.bind(this));
xmppMessages.registerXmppMessageHandler("chat", "nick", this.onNickChange.bind(this));
}
onNickChange(message)
{
if (message.oldnick == g_Nickname)
g_Nickname = message.newnick;
}
onClientLeave(message)
{
if (message.nick == g_Nickname)
Engine.DisconnectXmppClient();
}
onClientKicked(banned, message)
{
if (message.nick != g_Nickname)
return;
this.kicked = true;
// The current player has been kicked from the room, not from the server
Engine.DisconnectXmppClient();
messageBox(
400, 250,
new KickStrings().get(banned, message),
banned ? translate("BANNED") : translate("KICKED"));
}
askReconnect()
{
if (this.kicked)
return;
// Ignore stacked disconnect messages
if (Engine.IsXmppClientConnected() || this.askingReconnect)
return;
this.askingReconnect = true;
messageBox(
400, 200,
translate("You have been disconnected from the lobby. Do you want to reconnect?"),
translate("Confirmation"),
[translate("No"), translate("Yes")],
[
() => {
this.askingReconnect = false;
},
() => {
this.askingReconnect = false;
Engine.ConnectXmppClient();
}
]);
}
}

View File

@ -0,0 +1,338 @@
/**
* This class represents a multiplayer match hosted by a player in the lobby.
* Having this represented as a class allows to leverage significant performance
* gains by caching computed, escaped, translated strings and sorting keys.
*
* Additionally class representation allows implementation of events such as
* a new match being hosted, a match having ended, or a buddy having joined a match.
*
* Ensure that escapeText is applied to player controlled data for display.
*
* Users of the properties of this class:
* GameList, GameDetails, MapFilters, JoinButton, any user of GameList.selectedGame()
*/
class Game
{
constructor()
{
// Stanza data, object with exclusively string values
// Used to compare which part of the stanza data changed,
// perform partial updates and trigger event notifications.
this.stanza = {};
for (let name of this.StanzaKeys)
this.stanza[name] = "";
// This will be displayed in the GameList and GameDetails
// Important: Player input must be processed with escapeText
this.displayData = {
"tags": {}
};
// Cache the values used for sorting
this.sortValues = {
"state": "",
"compatibility": "",
"hasBuddyString": ""
};
// Array of objects, result of stringifiedTeamListToPlayerData
this.players = [];
// Whether the current player has the same mods launched as the host of this game
this.isCompatible = undefined;
// Used to display which mods are missing if the player attempts a join
this.mods = undefined;
// Used by the rating column and rating filer
this.gameRating = undefined;
// 'Persistent temporary' sprintf arguments object to avoid repeated object construction
this.playerCountArgs = {};
}
/**
* Called from GameList to ensure call order.
*/
onBuddyChange()
{
this.updatePlayers(this.stanza);
}
/**
* This function computes values that will either certainly or
* most likely be used later (i.e. by filtering, sorting and gamelist display).
*
* The performance benefit arises from the fact that for a new gamelist stanza
* many if not most games and game properties did not change.
*/
update(newStanza, sortKey)
{
let oldStanza = this.stanza;
let displayData = this.displayData;
let sortValues = this.sortValues;
if (oldStanza.name != newStanza.name)
{
Engine.ProfileStart("gameName");
sortValues.gameName = newStanza.name.toLowerCase();
this.updateGameName(newStanza);
Engine.ProfileStop();
}
if (oldStanza.state != newStanza.state)
{
Engine.ProfileStart("gameState");
this.updateGameTags(newStanza);
sortValues.state = this.GameStatusOrder.indexOf(newStanza.state);
Engine.ProfileStop();
}
if (oldStanza.niceMapName != newStanza.niceMapName)
{
Engine.ProfileStart("niceMapName");
displayData.mapName = escapeText(translateMapTitle(newStanza.niceMapName));
Engine.ProfileStop();
}
if (oldStanza.mapName != newStanza.mapName)
{
Engine.ProfileStart("mapName");
sortValues.mapName = displayData.mapName;
Engine.ProfileStop();
}
if (oldStanza.mapType != newStanza.mapType)
{
Engine.ProfileStart("mapType");
displayData.mapType = g_MapTypes.Title[g_MapTypes.Name.indexOf(newStanza.mapType)] || "";
sortValues.mapType = newStanza.mapType;
Engine.ProfileStop();
}
if (oldStanza.mapSize != newStanza.mapSize)
{
Engine.ProfileStart("mapSize");
displayData.mapSize = translateMapSize(newStanza.mapSize);
sortValues.mapSize = newStanza.mapSize;
Engine.ProfileStop();
}
let playersChanged = oldStanza.players != newStanza.players;
if (playersChanged)
{
Engine.ProfileStart("playerData");
this.updatePlayers(newStanza);
Engine.ProfileStop();
}
if (oldStanza.nbp != newStanza.nbp ||
oldStanza.maxnbp != newStanza.maxnbp ||
playersChanged)
{
Engine.ProfileStart("playerCount");
displayData.playerCount = this.getTranslatedPlayerCount(newStanza);
sortValues.maxnbp = newStanza.maxnbp;
Engine.ProfileStop();
}
if (oldStanza.mods != newStanza.mods)
{
Engine.ProfileStart("mods");
this.updateMods(newStanza);
Engine.ProfileStop();
}
this.stanza = newStanza;
this.sortValue = this.sortValues[sortKey];
}
updatePlayers(newStanza)
{
let players;
{
Engine.ProfileStart("stringifiedTeamListToPlayerData");
players = stringifiedTeamListToPlayerData(newStanza.players);
this.players = players;
Engine.ProfileStop();
}
{
Engine.ProfileStart("parsePlayers");
let observerCount = 0;
let hasBuddies = 0;
let playerRatingTotal = 0;
for (let player of players)
{
let playerNickRating = splitRatingFromNick(player.Name);
if (player.Team == "observer")
++observerCount;
else
playerRatingTotal += playerNickRating.rating || g_DefaultLobbyRating;
// Sort games with playing buddies above games with spectating buddies
if (hasBuddies < 2 && g_Buddies.indexOf(playerNickRating.nick) != -1)
hasBuddies = player.Team == "observer" ? 1 : 2;
}
this.observerCount = observerCount;
this.hasBuddies = hasBuddies;
let displayData = this.displayData;
let sortValues = this.sortValues;
displayData.buddy = this.hasBuddies ? setStringTags(g_BuddySymbol, displayData.tags) : "";
sortValues.hasBuddyString = String(hasBuddies);
sortValues.buddy = sortValues.hasBuddyString + sortValues.gameName;
let playerCount = players.length - observerCount;
let gameRating =
playerCount ?
Math.round(playerRatingTotal / playerCount) :
g_DefaultLobbyRating;
this.gameRating = gameRating;
sortValues.gameRating = gameRating;
Engine.ProfileStop();
}
}
updateMods(newStanza)
{
{
Engine.ProfileStart("JSON.parse");
try
{
this.mods = JSON.parse(newStanza.mods);
}
catch (e)
{
this.mods = [];
}
Engine.ProfileStop();
}
{
Engine.ProfileStart("hasSameMods");
let isCompatible = this.mods && hasSameMods(this.mods, Engine.GetEngineInfo().mods);
if (this.isCompatible != isCompatible)
{
this.isCompatible = isCompatible;
this.updateGameTags(newStanza);
this.sortValues.compatibility = String(isCompatible);
}
Engine.ProfileStop();
}
}
updateGameTags(newStanza)
{
let displayData = this.displayData;
displayData.tags = this.isCompatible ? this.StateTags[newStanza.state] : this.IncompatibleTags;
displayData.buddy = this.hasBuddies ? setStringTags(g_BuddySymbol, displayData.tags) : "";
this.updateGameName(newStanza);
}
updateGameName(newStanza)
{
let displayData = this.displayData;
displayData.gameName = setStringTags(escapeText(newStanza.name), displayData.tags);
let sortValues = this.sortValues;
sortValues.gameName = sortValues.compatibility + sortValues.state + sortValues.gameName;
sortValues.buddy = sortValues.hasBuddyString + sortValues.gameName;
}
getTranslatedPlayerCount(newStanza)
{
let playerCountArgs = this.playerCountArgs;
playerCountArgs.current = setStringTags(escapeText(newStanza.nbp), this.PlayerCountTags.CurrentPlayers);
playerCountArgs.max = setStringTags(escapeText(newStanza.maxnbp), this.PlayerCountTags.MaxPlayers);
let txt;
if (this.observerCount)
{
playerCountArgs.observercount = setStringTags(this.observerCount, this.PlayerCountTags.Observers);
txt = this.PlayerCountObservers;
}
else
txt = this.PlayerCountNoObservers;
return sprintf(txt, playerCountArgs);
}
}
/**
* These are all keys that occur in a gamelist stanza sent by XPartaMupp.
*/
Game.prototype.StanzaKeys = [
"name",
"ip",
"port",
"stunIP",
"stunPort",
"hostUsername",
"state",
"nbp",
"maxnbp",
"players",
"mapName",
"niceMapName",
"mapSize",
"mapType",
"victoryConditions",
"startTime",
"mods"
];
/**
* Initial sorting order of the gamelist.
*/
Game.prototype.GameStatusOrder = [
"init",
"waiting",
"running"
];
// Translation: The number of players and observers in this game
Game.prototype.PlayerCountObservers = translate("%(current)s/%(max)s +%(observercount)s");
// Translation: The number of players in this game
Game.prototype.PlayerCountNoObservers = translate("%(current)s/%(max)s");
/**
* Compatible games will be listed in these colors.
*/
Game.prototype.StateTags = {
"init": {
"color": "0 219 0"
},
"waiting": {
"color": "255 127 0"
},
"running": {
"color": "219 0 0"
}
};
/**
* Games that require different mods than the ones launched by the current player are grayed out.
*/
Game.prototype.IncompatibleTags = {
"color": "gray"
};
/**
* Color for the player count number in the games list.
*/
Game.prototype.PlayerCountTags = {
"CurrentPlayers": {
"color": "0 160 160"
},
"MaxPlayers": {
"color": "0 160 160"
},
"Observers": {
"color": "0 128 128"
}
};

View File

@ -0,0 +1,141 @@
/**
* The purpose of this class is to display information about the selected game.
*/
class GameDetails
{
constructor(dialog, gameList)
{
this.playernameArgs = {};
this.playerCountArgs = {};
this.gameStartArgs = {};
this.lastGame = {};
this.gameDetails = Engine.GetGUIObjectByName("gameDetails");
this.sgMapName = Engine.GetGUIObjectByName("sgMapName");
this.sgGame = Engine.GetGUIObjectByName("sgGame");
this.sgPlayersNames = Engine.GetGUIObjectByName("sgPlayersNames");
this.sgMapSize = Engine.GetGUIObjectByName("sgMapSize");
this.sgMapPreview = Engine.GetGUIObjectByName("sgMapPreview");
this.sgMapDescription = Engine.GetGUIObjectByName("sgMapDescription");
gameList.registerSelectionChangeHandler(this.onGameListSelectionChange.bind(this));
this.resize(dialog);
}
resize(dialog)
{
let bottom = Engine.GetGUIObjectByName(dialog ? "leaveButton" : "joinButton").size.top - 5;
let size = this.gameDetails.size;
size.bottom = bottom;
this.gameDetails.size = size;
}
/**
* Populate the game info area with information on the current game selection.
*/
onGameListSelectionChange(game)
{
this.gameDetails.hidden = !game;
if (!game)
return;
Engine.ProfileStart("GameDetails");
let stanza = game.stanza;
if (stanza.mapType != this.lastGame.mapType || stanza.mapName != this.lastGame.mapName)
{
let mapData = getMapDescriptionAndPreview(stanza.mapType, stanza.mapName);
this.sgMapPreview.sprite = getMapPreviewImage(mapData.preview);
this.mapDescription = mapData.description;
}
let displayData = game.displayData;
this.sgMapName.caption = displayData.mapName;
{
let txt;
if (game.isCompatible)
txt =
setStringTags(this.VictoryConditionsFormat, this.CaptionTags) + " " +
(stanza.victoryConditions ?
stanza.victoryConditions.split(",").map(translateVictoryCondition).join(this.Comma) :
translateWithContext("victory condition", "Endless Game"));
else
txt =
setStringTags(this.ModsFormat, this.CaptionTags) + " " +
escapeText(modsToString(game.mods, Engine.GetEngineInfo().mods));
txt +=
"\n" + setStringTags(this.MapTypeFormat, this.CaptionTags) + " " + displayData.mapType +
"\n" + setStringTags(this.MapSizeFormat, this.CaptionTags) + " " + displayData.mapSize +
"\n" + setStringTags(this.MapDescriptionFormat, this.CaptionTags) + " " + this.mapDescription;
this.sgMapDescription.caption = txt;
}
{
let txt = escapeText(stanza.name);
this.playernameArgs.playername = escapeText(stanza.hostUsername);
txt += "\n" + sprintf(this.HostFormat, this.playernameArgs);
this.playerCountArgs.current = escapeText(stanza.nbp);
this.playerCountArgs.total = escapeText(stanza.maxnbp);
txt += "\n" + sprintf(this.PlayerCountFormat, this.playerCountArgs);
if (stanza.startTime)
{
this.gameStartArgs.time = Engine.FormatMillisecondsIntoDateStringLocal(+stanza.startTime * 1000, this.TimeFormat);
txt += "\n" + sprintf(this.GameStartFormat, this.gameStartArgs);
}
this.sgGame.caption = txt;
}
{
let textHeight = this.sgGame.getTextSize().height;
let sgGameSize = this.sgGame.size;
sgGameSize.bottom = textHeight;
this.sgGame.size = sgGameSize;
let sgPlayersNamesSize = this.sgPlayersNames.size;
sgPlayersNamesSize.top = textHeight + 5;
this.sgPlayersNames.size = sgPlayersNamesSize;
}
this.sgPlayersNames.caption = formatPlayerInfo(game.players);
this.lastGame = game;
Engine.ProfileStop();
}
}
GameDetails.prototype.HostFormat = translate("Host: %(playername)s");
GameDetails.prototype.PlayerCountFormat = translate("Players: %(current)s/%(total)s");
GameDetails.prototype.VictoryConditionsFormat = translate("Victory Conditions:");
// Translation: Comma used to concatenate victory conditions
GameDetails.prototype.Comma = translate(", ");
GameDetails.prototype.ModsFormat = translate("Mods:");
// Translation: %(time)s is the hour and minute here.
GameDetails.prototype.GameStartFormat = translate("Game started at %(time)s");
GameDetails.prototype.TimeFormat = translate("HH:mm");
GameDetails.prototype.MapTypeFormat = translate("Map Type:");
GameDetails.prototype.MapSizeFormat = translate("Map Size:");
GameDetails.prototype.MapDescriptionFormat = translate("Map Description:");
GameDetails.prototype.CaptionTags = {
"font": "sans-bold-14"
};

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<object type="image" sprite="ModernDarkBoxGold">
<!-- Map Name -->
<object name="sgMapName" size="0 5 100% 20" type="text" style="ModernLabelText"/>
<!-- Map Preview -->
<object name="sgMapPreview" size="5 25 100%-5 185" type="image"/>
<!-- Map Description -->
<object type="image" sprite="ModernDarkBoxWhite" size="5 190 100%-5 60%">
<object name="sgMapDescription" type="text" style="ModernText" font="sans-14"/>
</object>
<object type="image" sprite="ModernDarkBoxWhite" size="5 60%+5 100%-5 100%-1">
<!-- Number of players, hostname, gamestart time -->
<object name="sgGame" size="5 5 100%-5 5" type="text" style="ModernLabelText"/>
<!-- Player Names -->
<object name="sgPlayersNames" type="text" style="MapPlayerList"/>
</object>
</object>

View File

@ -0,0 +1,221 @@
/**
* Each property of this class handles one specific map filter and is defined in external files.
*/
class GameListFilters
{
}
/**
* This class displays the list of multiplayer matches that are currently being set up or running,
* filtered and sorted depending on player selection.
*/
class GameList
{
constructor(xmppMessages, buddyButton)
{
// Array of Game class instances, where the keys are ip+port strings, used for quick lookups
this.games = {};
// Array of Game class instances sorted by display order
this.gameList = [];
this.selectionChangeHandlers = new Set();
this.gamesBox = Engine.GetGUIObjectByName("gamesBox");
this.gamesBox.onSelectionChange = this.onSelectionChange.bind(this);
this.gamesBox.onSelectionColumnChange = this.onFilterChange.bind(this);
let ratingColumn = Engine.ConfigDB_GetValue("user", "lobby.columns.gamerating") == "true";
this.gamesBox.hidden_mapType = ratingColumn;
this.gamesBox.hidden_gameRating = !ratingColumn;
// Avoid repeated array construction
this.list_buddy = [];
this.list_gameName = [];
this.list_mapName = [];
this.list_mapSize = [];
this.list_mapType = [];
this.list_maxnbp = [];
this.list_gameRating = [];
this.list = [];
this.filters = [];
for (let name in GameListFilters)
this.filters.push(new GameListFilters[name](this.onFilterChange.bind(this)));
xmppMessages.registerXmppMessageHandler("game", "gamelist", this.rebuildGameList.bind(this));
xmppMessages.registerXmppMessageHandler("system", "disconnected", this.rebuildGameList.bind(this));
buddyButton.registerBuddyChangeHandler(this.onBuddyChange.bind(this));
this.rebuildGameList();
}
registerSelectionChangeHandler(handler)
{
this.selectionChangeHandlers.add(handler);
}
onFilterChange()
{
this.rebuildGameList();
}
onBuddyChange()
{
for (let name in this.games)
this.games[name].onBuddyChange();
this.rebuildGameList();
}
onSelectionChange()
{
let game = this.selectedGame();
for (let handler of this.selectionChangeHandlers)
handler(game);
}
selectedGame()
{
return this.gameList[this.gamesBox.selected] || undefined;
}
rebuildGameList()
{
Engine.ProfileStart("rebuildGameList");
Engine.ProfileStart("getGameList");
let selectedGame = this.selectedGame();
let gameListData = Engine.GetGameList();
Engine.ProfileStop();
{
Engine.ProfileStart("updateGames");
let selectedColumn = this.gamesBox.selected_column;
let newGames = {};
for (let stanza of gameListData)
{
let game = this.games[stanza.ip] || undefined;
let exists = !!game;
if (!exists)
game = new Game();
game.update(stanza, selectedColumn);
newGames[stanza.ip] = game;
}
this.games = newGames;
Engine.ProfileStop();
}
{
Engine.ProfileStart("filterGameList");
this.gameList.length = 0;
for (let ip in this.games)
{
let game = this.games[ip];
if (this.filters.every(filter => filter.filter(game)))
this.gameList.push(game);
}
Engine.ProfileStop();
}
{
Engine.ProfileStart("sortGameList");
let sortOrder = this.gamesBox.selected_column_order;
this.gameList.sort((game1, game2) => {
if (game1.sortValue < game2.sortValue) return -sortOrder;
if (game1.sortValue > game2.sortValue) return +sortOrder;
return 0;
});
Engine.ProfileStop();
}
let selectedGameIndex = -1;
{
Engine.ProfileStart("setupGameList");
let length = this.gameList.length;
this.list_buddy.length = length;
this.list_gameName.length = length;
this.list_mapName.length = length;
this.list_mapSize.length = length;
this.list_mapType.length = length;
this.list_maxnbp.length = length;
this.list_gameRating.length = length;
this.list.length = length;
this.gameList.forEach((game, i) => {
let displayData = game.displayData;
this.list_buddy[i] = displayData.buddy;
this.list_gameName[i] = displayData.gameName;
this.list_mapName[i] = displayData.mapName;
this.list_mapSize[i] = displayData.mapSize;
this.list_mapType[i] = displayData.mapType;
this.list_maxnbp[i] = displayData.playerCount;
this.list_gameRating[i] = game.gameRating;
this.list[i] = "";
if (selectedGame && game.stanza.ip == selectedGame.stanza.ip && game.stanza.port == selectedGame.stanza.port)
selectedGameIndex = i;
});
Engine.ProfileStop();
}
{
Engine.ProfileStart("copyToGUI");
let gamesBox = this.gamesBox;
gamesBox.list_buddy = this.list_buddy;
gamesBox.list_gameName = this.list_gameName;
gamesBox.list_mapName = this.list_mapName;
gamesBox.list_mapSize = this.list_mapSize;
gamesBox.list_mapType = this.list_mapType;
gamesBox.list_maxnbp = this.list_maxnbp;
gamesBox.list_gameRating = this.list_gameRating;
// Change these last, otherwise crash
gamesBox.list = this.list;
gamesBox.list_data = this.list;
gamesBox.auto_scroll = false;
Engine.ProfileStop();
gamesBox.selected = selectedGameIndex;
}
Engine.ProfileStop();
}
/**
* Select the game where the selected player is currently playing, observing or offline.
* Selects in that order to account for players that occur in multiple games.
*/
selectGameFromPlayername(playerName)
{
if (!playerName)
return;
let foundAsObserver = false;
for (let i = 0; i < this.gameList.length; ++i)
for (let player of this.gameList[i].players)
{
if (playerName != splitRatingFromNick(player.Name).nick)
continue;
this.gamesBox.auto_scroll = true;
if (player.Team == "observer")
{
foundAsObserver = true;
this.gamesBox.selected = i;
}
else if (!player.Offline)
{
this.gamesBox.selected = i;
return;
}
if (!foundAsObserver)
this.gamesBox.selected = i;
}
}
}

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<object name="gamesBox"
style="ModernSortedList"
selected_column="gameName"
selected_column_order="1"
type="olist"
sortable="true"
font="sans-stroke-13"
>
<column id="buddy" width="12"/>
<column id="gameName" color="128 128 128" width="33%-12">
<translatableAttribute id="heading">Name</translatableAttribute>
</column>
<column id="mapName" color="128 128 128" width="25%">
<translatableAttribute id="heading">Map Name</translatableAttribute>
</column>
<column id="mapSize" color="128 128 128" width="16%">
<translatableAttribute id="heading" context="map">Size</translatableAttribute>
</column>
<column id="mapType" color="0 160 160" width="16%">
<translatableAttribute id="heading" context="map">Type</translatableAttribute>
</column>
<column id="maxnbp" color="0 128 128" width="10%">
<translatableAttribute id="heading">Players</translatableAttribute>
</column>
<column id="gameRating" color="0 160 160" width="16%">
<translatableAttribute id="heading">Rating</translatableAttribute>
</column>
</object>

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<object name="filterPanel">
<object type="text" size="22 0 52%-10 100%" text_align="left" textcolor="white">
<translatableAttribute id="caption">Show only open games</translatableAttribute>
</object>
<object name="filterOpenGames" type="checkbox" checked="false" style="ModernTickBox" size="0 0 20 20"/>
<object name="mapSizeFilter"
type="dropdown"
style="ModernDropDown"
size="58%-5 0 74%-10 100%"
font="sans-bold-13">
</object>
<object name="mapTypeFilter"
type="dropdown"
style="ModernDropDown"
size="74%-5 0 90%-10 100%"
font="sans-bold-13">
</object>
<object name="playersNumberFilter"
type="dropdown"
style="ModernDropDown"
size="90%-5 0 100%-10 100%"
font="sans-bold-13">
</object>
<object name="gameRatingFilter"
type="dropdown"
style="ModernDropDown"
size="84%-5 0 100%-10 100%"
font="sans-bold-13">
</object>
</object>

View File

@ -0,0 +1,25 @@
GameListFilters.MapSize = class
{
constructor(onFilterChange)
{
this.selected = 0;
this.onFilterChange = onFilterChange;
this.mapSizeFilter = Engine.GetGUIObjectByName("mapSizeFilter");
this.mapSizeFilter.list = [translateWithContext("map size", "Any"), ...g_MapSizes.Name];
this.mapSizeFilter.list_data = ["", ...g_MapSizes.Tiles];
this.mapSizeFilter.selected = 0;
this.mapSizeFilter.onSelectionChange = this.onSelectionChange.bind(this);
}
onSelectionChange()
{
this.selected = this.mapSizeFilter.list_data[this.mapSizeFilter.selected];
this.onFilterChange();
}
filter(game)
{
return !this.selected || game.stanza.mapSize == this.selected;
}
};

View File

@ -0,0 +1,25 @@
GameListFilters.MapType = class
{
constructor(onFilterChange)
{
this.selected = "";
this.onFilterChange = onFilterChange;
this.mapTypeFilter = Engine.GetGUIObjectByName("mapTypeFilter");
this.mapTypeFilter.list = [translateWithContext("map", "Any")].concat(g_MapTypes.Title);
this.mapTypeFilter.list_data = [""].concat(g_MapTypes.Name);
this.mapTypeFilter.selected = g_MapTypes.Default;
this.mapTypeFilter.onSelectionChange = this.onSelectionChange.bind(this);
}
onSelectionChange()
{
this.selected = this.mapTypeFilter.list_data[this.mapTypeFilter.selected];
this.onFilterChange();
}
filter(game)
{
return !this.selected || game.stanza.mapType == this.selected;
}
};

View File

@ -0,0 +1,24 @@
GameListFilters.OpenGame = class
{
constructor(onFilterChange)
{
this.checked = false;
this.onFilterChange = onFilterChange;
this.filterOpenGames = Engine.GetGUIObjectByName("filterOpenGames");
this.filterOpenGames.checked = false;
this.filterOpenGames.onPress = this.onPress.bind(this);
}
onPress()
{
this.checked = this.filterOpenGames.checked;
this.onFilterChange();
}
filter(game)
{
let stanza = game.stanza;
return !this.checked || stanza.state == "init" && stanza.nbp < stanza.maxnbp;
}
};

View File

@ -0,0 +1,26 @@
GameListFilters.PlayerCount = class
{
constructor(onFilterChange)
{
this.selected = "";
this.onFilterChange = onFilterChange;
let playersArray = Array(g_MaxPlayers).fill(0).map((v, i) => i + 1); // 1, 2, ... MaxPlayers
this.playersNumberFilter = Engine.GetGUIObjectByName("playersNumberFilter");
this.playersNumberFilter.list = [translateWithContext("player number", "Any")].concat(playersArray);
this.playersNumberFilter.list_data = [""].concat(playersArray);
this.playersNumberFilter.selected = 0;
this.playersNumberFilter.onSelectionChange = this.onSelectionChange.bind(this);
}
onSelectionChange()
{
this.selected = this.playersNumberFilter.list_data[this.playersNumberFilter.selected];
this.onFilterChange();
}
filter(game)
{
return !this.selected || game.stanza.maxnbp == this.selected;
}
};

View File

@ -0,0 +1,75 @@
GameListFilters.Rating = class
{
constructor(onFilterChange)
{
this.enabled = undefined;
this.onFilterChange = onFilterChange;
this.filter = () => true;
this.gameRatingFilter = Engine.GetGUIObjectByName("gameRatingFilter");
this.gameRatingFilter.list = [
translateWithContext("map", "Any"),
...this.RatingFilters
];
this.gameRatingFilter.list_data = [
"",
...this.RatingFilters.map(r =>
sprintf(
r[0] == ">" ?
translateWithContext("gamelist filter", "> %(rating)s") :
translateWithContext("gamelist filter", "< %(rating)s"),
{
"rating": r.substr(1)
}))
];
this.gameRatingFilter.selected = 0;
this.gameRatingFilter.onSelectionChange = this.onSelectionChange.bind(this);
this.setEnabled(Engine.ConfigDB_GetValue("user", "lobby.columns.gamerating") == "true");
}
setEnabled(enabled)
{
this.enabled = enabled;
this.gameRatingFilter.hidden = !this.enabled;
if (!enabled)
return;
// TODO: COList should expose the precise column width
// Hide element to compensate width
let mapTypeFilter = Engine.GetGUIObjectByName("mapTypeFilter");
mapTypeFilter.hidden = enabled;
let playersNumberFilter = Engine.GetGUIObjectByName("playersNumberFilter");
let mapTypeFilterSize = mapTypeFilter.size;
let size = playersNumberFilter.size;
size.rleft = mapTypeFilterSize.rleft;
size.rright = this.gameRatingFilter.size.rleft;
playersNumberFilter.size = size;
}
onSelectionChange()
{
let selectedType = this.gameRatingFilter.list_data[this.gameRatingFilter.selected];
let selectedRating = +selectedType.substr(1);
this.filter =
(!this.enabled || !selectedType) ?
() => true :
selectedType.startsWith(">") ?
game => game.gameRating >= selectedRating :
game => game.gameRating < selectedRating;
this.onFilterChange();
}
};
GameListFilters.Rating.prototype.RatingFilters = [
">1500",
">1400",
">1300",
">1200",
"<1200",
"<1100",
"<1000"
];

View File

@ -0,0 +1,49 @@
/**
* This class provides translated kick event notification strings,
* consumed by chat and notification message box.
*/
class KickStrings
{
constructor()
{
this.nickArgs = {};
this.reasonArgs = {};
}
get(banned, message)
{
let level = banned ? 1 : 0;
let me = message.nick == g_Nickname;
let txt;
if (me)
txt = this.Strings.Local[level];
else
{
this.nickArgs.nick = escapeText(message.nick);
txt = sprintf(this.Strings.Remote[level], this.nickArgs);
}
if (message.reason)
{
this.reasonArgs.reason = escapeText(message.reason);
txt += " " + sprintf(this.Reason, this.reasonArgs);
}
return txt;
}
}
KickStrings.prototype.Strings = {
"Local": [
translate("You have been kicked from the lobby!"),
translate("You have been banned from the lobby!")
],
"Remote": [
translate("%(nick)s has been kicked from the lobby."),
translate("%(nick)s has been banned from the lobby.")
]
};
KickStrings.prototype.Reason =
translateWithContext("lobby kick", "Reason: %(reason)s");

View File

@ -0,0 +1,73 @@
/**
* This class stores the handlers for all GUI objects in the lobby page,
* (excluding other pages in the same context such as leaderboard and profile page).
*/
class LobbyPage
{
constructor(dialog, xmppMessages, leaderboardPage, profilePage)
{
Engine.ProfileStart("Create LobbyPage");
let buddyButton = new BuddyButton(xmppMessages);
let gameList = new GameList(xmppMessages, buddyButton);
let playerList = new PlayerList(xmppMessages, buddyButton, gameList);
this.lobbyPage = {
"buttons": {
"buddyButton": buddyButton,
"hostButton": new HostButton(dialog, xmppMessages),
"joinButton": new JoinButton(dialog, gameList),
"leaderboardButton": new LeaderboardButton(xmppMessages, leaderboardPage),
"profileButton": new ProfileButton(xmppMessages, profilePage),
"quitButton": new QuitButton(dialog, leaderboardPage, profilePage)
},
"panels": {
"chatPanel": new ChatPanel(xmppMessages),
"gameDetails": new GameDetails(dialog, gameList),
"gameList": gameList,
"playerList": playerList,
"profilePanel": new ProfilePanel(xmppMessages, playerList, leaderboardPage),
"subject": new Subject(dialog, xmppMessages, gameList)
},
"eventHandlers": {
"announcementHandler": new AnnouncementHandler(xmppMessages),
"connectionHandler": new ConnectionHandler(xmppMessages),
}
};
if (dialog)
this.setDialogStyle();
Engine.ProfileStop();
}
setDialogStyle()
{
{
let lobbyPage = Engine.GetGUIObjectByName("lobbyPage");
lobbyPage.sprite = "ModernDialog";
let size = lobbyPage.size;
size.left = this.WindowMargin;
size.top = this.WindowMargin;
size.right = -this.WindowMargin;
size.bottom = -this.WindowMargin;
lobbyPage.size = size;
}
{
let lobbyPageTitle = Engine.GetGUIObjectByName("lobbyPageTitle");
let size = lobbyPageTitle.size;
size.top -= this.WindowMargin / 2;
size.bottom -= this.WindowMargin / 2;
lobbyPageTitle.size = size;
}
{
let lobbyPanels = Engine.GetGUIObjectByName("lobbyPanels");
let size = lobbyPanels.size;
size.top -= this.WindowMargin / 2;
lobbyPanels.size = size;
}
}
}
LobbyPage.prototype.WindowMargin = 40;

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<object>
<!-- Translucent black background -->
<object type="image" sprite="ModernFade"/>
<object name="lobbyPage" type="image" sprite="ModernWindow">
<object name="lobbyPageTitle" type="text" style="ModernLabelText" size="50%-128 4 50%+128 36">
<translatableAttribute id="caption">Multiplayer Lobby</translatableAttribute>
</object>
<object name="lobbyPanels" size="0 40 100% 100%-20">
<object name="leftPanel" size="20 0 20% 100%+20">
<object size="0 0 100% 100%-315">
<include file="gui/lobby/LobbyPage/PlayerList.xml"/>
</object>
<object size="0 100%-310 100% 100%-104">
<include file="gui/lobby/LobbyPage/ProfilePanel.xml"/>
</object>
<object size="0 100%-98 100% 100%">
<object name="toggleBuddyButton" type="button" style="ModernButtonRed" size="0 0 100% 25"/>
<object name="leaderboardButton" type="button" style="ModernButtonRed" size="0 27 100% 52"/>
<object name="profileButton" type="button" style="ModernButtonRed" size="0 54 100% 79"/>
</object>
</object>
<object name="middlePanel" size="20%+5 0 100%-255 100%">
<object size="0 0 100% 24">
<include file="gui/lobby/LobbyPage/GameListFilters.xml"/>
</object>
<object size="0 25 100% 48%">
<include file="gui/lobby/LobbyPage/GameList.xml"/>
</object>
<object size="0 49% 100% 100%">
<include file="gui/lobby/LobbyPage/Chat/ChatPanel.xml"/>
</object>
</object>
<object name="rightPanel" size="100%-250 0 100%-20 100%">
<object name="gameDetails" hidden="true">
<include file="gui/lobby/LobbyPage/GameDetails.xml"/>
</object>
<object name="subjectPanel">
<include file="gui/lobby/LobbyPage/Subject.xml"/>
</object>
<object name="joinButton" size="0 100%-79 100% 100%-54" type="button" style="ModernButtonRed"/>
<object name="hostButton" size="0 100%-52 100% 100%-27" type="button" style="ModernButtonRed"/>
<object name="leaveButton" size="0 100%-25 100% 100%" type="button" style="ModernButtonRed"/>
</object>
</object>
</object>
</object>

View File

@ -0,0 +1,53 @@
/**
* The purpose of this class is to determine a color per playername and to apply that color,
* escape reserved characters and add a moderator prefix when displaying playernames.
*/
class PlayerColor
{
}
/**
* Generate a (mostly) unique color for this player based on their name.
* @see https://stackoverflow.com/questions/3426404/create-a-hexadecimal-colour-based-on-a-string-with-jquery-javascript
*/
PlayerColor.GetPlayerColor = function(playername)
{
// Generate a probably-unique hash for the player name and use that to create a color.
let hash = 0;
for (let i in playername)
hash = playername.charCodeAt(i) + ((hash << 5) - hash);
// First create the color in RGB then HSL, clamp the lightness so it's not too dark to read, and then convert back to RGB to display.
// The reason for this roundabout method is this algorithm can generate values from 0 to 255 for RGB but only 0 to 100 for HSL; this gives
// us much more variety if we generate in RGB. Unfortunately, enforcing that RGB values are a certain lightness is very difficult, so
// we convert to HSL to do the computation. Since our GUI code only displays RGB colors, we have to convert back.
let [h, s, l] = rgbToHsl(hash >> 24 & 0xFF, hash >> 16 & 0xFF, hash >> 8 & 0xFF);
return hslToRgb(h, s, Math.max(0.7, l)).join(" ");
};
/**
* Colorizes the given nickname with a color unique and deterministic for that player.
*/
PlayerColor.ColorPlayerName = function(playername, rating, role)
{
let name = rating ?
sprintf(translate("%(nick)s (%(rating)s)"), {
"nick": playername,
"rating": rating
}) :
playername;
if (role == "moderator")
name = PlayerColor.ModeratorPrefix + name;
return coloredText(escapeText(name), PlayerColor.GetPlayerColor(playername));
};
/**
* A symbol which is prepended to the nickname of moderators.
*/
PlayerColor.ModeratorPrefix = "@";
// TODO: Remove global required by formatPlayerInfo
var getPlayerColor = PlayerColor.GetPlayerColor;

View File

@ -0,0 +1,207 @@
/**
* This class is concerned with displaying players who are online and
* triggering handlers when selecting or doubleclicking on a player.
*/
class PlayerList
{
constructor(xmppMessages, buddyButton, gameList)
{
this.gameList = gameList;
this.selectedPlayer = undefined;
this.statusOrder = Object.keys(this.PlayerStatuses);
// Avoid repeated array construction for performance
this.buddyStatusList = [];
this.playerList = [];
this.presenceList = [];
this.nickList = [];
this.ratingList = [];
this.selectionChangeHandlers = new Set();
this.mouseLeftDoubleClickItemHandlers = new Set();
this.playersBox = Engine.GetGUIObjectByName("playersBox");
this.playersBox.onSelectionChange = this.onPlayerListSelection.bind(this);
this.playersBox.onSelectionColumnChange = this.rebuildPlayerList.bind(this);
this.playersBox.onMouseLeftClickItem = this.onMouseLeftClickItem.bind(this);
this.playersBox.onMouseLeftDoubleClickItem = this.onMouseLeftDoubleClickItem.bind(this);
buddyButton.registerBuddyChangeHandler(this.onBuddyChange.bind(this));
xmppMessages.registerPlayerListUpdateHandler(this.rebuildPlayerList.bind(this));
this.registerSelectionChangeHandler(buddyButton.onPlayerSelectionChange.bind(buddyButton));
this.registerMouseLeftDoubleClickItemHandler(buddyButton.onPress.bind(buddyButton));
this.rebuildPlayerList();
}
registerSelectionChangeHandler(handler)
{
this.selectionChangeHandlers.add(handler);
}
registerMouseLeftDoubleClickItemHandler(handler)
{
this.mouseLeftDoubleClickItemHandlers.add(handler);
}
onBuddyChange()
{
this.rebuildPlayerList();
}
onMouseLeftDoubleClickItem()
{
for (let handler of this.mouseLeftDoubleClickItemHandlers)
handler();
}
onMouseLeftClickItem()
{
// In case of clicking on the same player again
this.gameList.selectGameFromPlayername(this.selectedPlayer);
}
onPlayerListSelection()
{
if (this.playersBox.selected == this.playersBox.list.indexOf(this.selectedPlayer))
return;
this.selectedPlayer = this.playersBox.list[this.playersBox.selected];
this.gameList.selectGameFromPlayername(this.selectedPlayer);
for (let handler of this.selectionChangeHandlers)
handler(this.selectedPlayer);
}
parsePlayer(sortKey, player)
{
player.isBuddy = g_Buddies.indexOf(player.name) != -1;
switch (sortKey)
{
case 'buddy':
player.sortValue = (player.isBuddy ? 1 : 2) + this.statusOrder.indexOf(player.presence) + player.name.toLowerCase();
break;
case 'rating':
player.sortValue = +player.rating;
break;
case 'status':
player.sortValue = this.statusOrder.indexOf(player.presence) + player.name.toLowerCase();
break;
case 'name':
default:
player.sortValue = player.name.toLowerCase();
break;
}
}
/**
* Do a full update of the player listing, including ratings from cached C++ information.
* Important: This should only be performed once if
* there have been multiple messages received changing this list.
*/
rebuildPlayerList()
{
Engine.ProfileStart("rebuildPlaersList");
Engine.ProfileStart("getPlayerList");
let playerList = Engine.GetPlayerList();
Engine.ProfileStop();
Engine.ProfileStart("parsePlayers");
playerList.forEach(this.parsePlayer.bind(this, this.playersBox.selected_column));
Engine.ProfileStop();
Engine.ProfileStart("sortPlayers");
playerList.sort(this.sortPlayers.bind(this, this.playersBox.selected_column_order));
Engine.ProfileStop();
Engine.ProfileStart("prepareList");
let length = playerList.length;
this.buddyStatusList.length = length;
this.playerList.length = length;
this.presenceList.length = length;
this.nickList.length = length;
this.ratingList.length = length;
playerList.forEach((player, i) => {
// TODO: COList.cpp columns should support horizontal center align
let rating = player.rating ? (" " + player.rating).substr(-5) : " -";
let presence = this.PlayerStatuses[player.presence] ? player.presence : "unknown";
if (presence == "unknown")
warn("Unknown presence:" + player.presence);
let statusTags = this.PlayerStatuses[presence].tags;
this.buddyStatusList[i] = player.isBuddy ? setStringTags(g_BuddySymbol, statusTags) : "";
this.playerList[i] = PlayerColor.ColorPlayerName(player.name, "", player.role);
this.presenceList[i] = setStringTags(this.PlayerStatuses[presence].status, statusTags);
this.ratingList[i] = setStringTags(rating, statusTags);
this.nickList[i] = escapeText(player.name);
});
Engine.ProfileStop();
Engine.ProfileStart("copyToGUI");
this.playersBox.list_buddy = this.buddyStatusList;
this.playersBox.list_name = this.playerList;
this.playersBox.list_status = this.presenceList;
this.playersBox.list_rating = this.ratingList;
this.playersBox.list = this.nickList;
Engine.ProfileStop();
Engine.ProfileStart("selectionChange");
this.playersBox.selected = this.playersBox.list.indexOf(this.selectedPlayer);
Engine.ProfileStop();
Engine.ProfileStop();
}
sortPlayers(sortOrder, player1, player2)
{
if (player1.sortValue < player2.sortValue)
return -sortOrder;
if (player1.sortValue > player2.sortValue)
return +sortOrder;
return 0;
}
}
/**
* The playerlist will be assembled using these values.
*/
PlayerList.prototype.PlayerStatuses = {
"available": {
"status": translate("Online"),
"tags": {
"color": "0 219 0"
}
},
"away": {
"status": translate("Away"),
"tags": {
"color": "229 76 13"
}
},
"playing": {
"status": translate("Busy"),
"tags": {
"color": "200 0 0"
}
},
"offline": {
"status": translate("Offline"),
"tags": {
"color": "0 0 0"
}
},
"unknown": {
"status": translateWithContext("lobby presence", "Unknown"),
"tags": {
"color": "178 178 178"
}
}
};

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<object name="playersBox"
style="ModernSortedList"
selected_column="name"
selected_column_order="1"
type="olist"
sortable="true"
font="sans-bold-stroke-13"
>
<column id="buddy" width="12"/>
<column id="status" width="26%">
<translatableAttribute id="heading">Status</translatableAttribute>
</column>
<column id="name" width="48%-12">
<translatableAttribute id="heading">Name</translatableAttribute>
</column>
<column id="rating" width="26%">
<translatableAttribute id="heading">Rating</translatableAttribute>
</column>
</object>

View File

@ -0,0 +1,125 @@
/**
* This class fetches and displays player profile data,
* where the player had been selected in the playerlist or leaderboard.
*/
class ProfilePanel
{
constructor(xmppMessages, playerList, leaderboardPage)
{
// Playerlist or leaderboard selection
this.requestedPlayer = undefined;
// Playerlist selection
this.selectedPlayer = undefined;
this.roleText = Engine.GetGUIObjectByName("roleText");
this.ratioText = Engine.GetGUIObjectByName("ratioText");
this.lossesText = Engine.GetGUIObjectByName("lossesText");
this.winsText = Engine.GetGUIObjectByName("winsText");
this.totalGamesText = Engine.GetGUIObjectByName("totalGamesText");
this.highestRatingText = Engine.GetGUIObjectByName("highestRatingText");
this.rankText = Engine.GetGUIObjectByName("rankText");
this.fade = Engine.GetGUIObjectByName("fade");
this.playernameText = Engine.GetGUIObjectByName("playernameText");
this.profileArea = Engine.GetGUIObjectByName("profileArea");
xmppMessages.registerXmppMessageHandler("game", "profile", this.onProfile.bind(this));
xmppMessages.registerXmppMessageHandler("chat", "role", this.onRoleChange.bind(this));
playerList.registerSelectionChangeHandler(this.onPlayerListSelection.bind(this));
leaderboardPage.registerOpenPageHandler(this.onLeaderboardOpenPage.bind(this));
leaderboardPage.registerClosePageHandler(this.onLeaderboardClosePage.bind(this));
leaderboardPage.leaderboardList.registerSelectionChangeHandler(this.onLeaderboardSelectionChange.bind(this));
}
onPlayerListSelection(playerName)
{
this.selectedPlayer = playerName;
this.requestProfile(playerName);
}
onRoleChange(message)
{
if (message.nick == this.requestedPlayer)
this.updatePlayerRoleText(this.requestedPlayer);
}
onLeaderboardOpenPage(playerName)
{
this.requestProfile(playerName);
}
onLeaderboardSelectionChange(playerName)
{
this.requestProfile(playerName);
}
onLeaderboardClosePage()
{
this.requestProfile(this.selectedPlayer);
}
updatePlayerRoleText(playerName)
{
this.roleText.caption = this.RoleNames[Engine.LobbyGetPlayerRole(playerName) || "participant"];
}
requestProfile(playerName)
{
this.profileArea.hidden = !playerName && !this.playernameText.caption;
this.requestedPlayer = playerName;
if (!playerName)
return;
this.playernameText.caption = playerName;
this.updatePlayerRoleText(playerName);
this.rankText.caption = this.NotAvailable;
this.highestRatingText.caption = this.NotAvailable;
this.totalGamesText.caption = this.NotAvailable;
this.winsText.caption = this.NotAvailable;
this.lossesText.caption = this.NotAvailable;
this.ratioText.caption = this.NotAvailable;
Engine.SendGetProfile(playerName);
}
onProfile()
{
let attributes = Engine.GetProfile()[0];
if (attributes.rating == "-2" || attributes.player != this.requestedPlayer)
return;
this.playernameText.caption = attributes.player;
this.updatePlayerRoleText(attributes.player);
this.rankText.caption = attributes.rank;
this.highestRatingText.caption = attributes.highestRating;
this.totalGamesText.caption = attributes.totalGamesPlayed;
this.winsText.caption = attributes.wins;
this.lossesText.caption = attributes.losses;
this.ratioText.caption = ProfilePanel.FormatWinRate(attributes);
}
}
ProfilePanel.prototype.NotAvailable = translate("N/A");
/**
* These role names correspond to the names constructed by the XmppClient.
*/
ProfilePanel.prototype.RoleNames = {
"moderator": translate("Moderator"),
"participant": translate("Player"),
"visitor": translate("Muted Player")
};
ProfilePanel.FormatWinRate = function(attr)
{
if (!attr.totalGamesPlayed)
return translateWithContext("Used for an undefined winning rate", "-");
return sprintf(translate("%(percentage)s%%"), {
"percentage": (attr.wins / attr.totalGamesPlayed * 100).toFixed(2)
});
};

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This is the display of the player selected in the playerlist, not the profile page. -->
<object name="profileBox" type="image" sprite="ModernDarkBoxGold">
<object name="profileArea" hidden="true">
<object name="playernameText" size="0 0 100% 45" type="text" style="ModernLabelText" font="sans-bold-16" />
<object name="roleText" size="0 45 100% 70" type="text" style="ModernLabelText" font="sans-bold-stroke-12" />
<object size="0 70 40%+40 90" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Current Rank:</translatableAttribute>
</object>
<object name="rankText" size="40%+45 70 100% 90" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 90 40%+40 110" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Highest Rating:</translatableAttribute>
</object>
<object name="highestRatingText" size="40%+45 90 100% 110" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 110 40%+40 130" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Total Games:</translatableAttribute>
</object>
<object name="totalGamesText" size="40%+45 110 100% 130" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 130 40%+40 150" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Wins:</translatableAttribute>
</object>
<object name="winsText" size="40%+45 130 100% 150" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 150 40%+40 170" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Losses:</translatableAttribute>
</object>
<object name="lossesText" size="40%+45 150 100% 170" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 170 40%+40 190" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Win Rate:</translatableAttribute>
</object>
<object name="ratioText" size="40%+45 170 100% 190" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
</object>
</object>

View File

@ -0,0 +1,45 @@
/**
* The purpose of this class is to display the room subject in a panel and to
* update it when a new one was received.
*/
// TODO: It should be easily possible to view the subject after a game has been selected
class Subject
{
constructor(dialog, xmppMessages, gameList)
{
this.subjectPanel = Engine.GetGUIObjectByName("subjectPanel");
this.subjectText = Engine.GetGUIObjectByName("subjectText");
this.subjectBox = Engine.GetGUIObjectByName("subjectBox");
this.logoTop = Engine.GetGUIObjectByName("logoTop");
this.logoCenter = Engine.GetGUIObjectByName("logoCenter");
this.updateSubject(Engine.LobbyGetRoomSubject());
xmppMessages.registerXmppMessageHandler("chat", "subject", this.onSubject.bind(this));
gameList.registerSelectionChangeHandler(this.onGameListSelectionChange.bind(this));
let bottom = Engine.GetGUIObjectByName(dialog ? "leaveButton" : "hostButton").size.top - 5;
let size = this.subjectPanel.size;
size.bottom = bottom;
this.subjectPanel.size = size;
}
onGameListSelectionChange(game)
{
this.subjectPanel.hidden = !!game;
}
onSubject(message)
{
this.updateSubject(message.subject);
}
updateSubject(subject)
{
subject = subject.trim();
this.subjectBox.hidden = !subject;
this.subjectText.caption = subject;
this.logoTop.hidden = !subject;
this.logoCenter.hidden = !!subject;
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<object type="image" sprite="ModernDarkBoxGold">
<object name="logoTop" size="50%-110 40 50%+110 140" type="image" sprite="logo"/>
<object name="logoCenter" size="50%-110 50%-50 50%+110 50%+50" type="image" sprite="logo"/>
<object name="subjectBox" size="5 180 100%-5 100%-10" type="image" sprite="ModernDarkBoxWhite">
<object name="subjectText" size="5 5 100%-5 100%-10" type="text" style="ModernText" text_align="center"/>
</object>
</object>

View File

@ -0,0 +1,84 @@
/**
* The profile page enables the player to lookup statistics of an arbitrary player.
*/
class ProfilePage
{
constructor(xmppMessages)
{
this.requestedPlayer = undefined;
this.closePageHandlers = new Set();
this.profilePage = Engine.GetGUIObjectByName("profilePage");
this.fetchInput = Engine.GetGUIObjectByName("fetchInput");
this.fetchInput.onPress = this.onPressLookup.bind(this);
Engine.GetGUIObjectByName("viewProfileButton").onPress = this.onPressLookup.bind(this);
Engine.GetGUIObjectByName("profileBackButton").onPress = this.onPressClose.bind(this, true);
this.profilePlayernameText = Engine.GetGUIObjectByName("profilePlayernameText");
this.profileRankText = Engine.GetGUIObjectByName("profileRankText");
this.profileHighestRatingText = Engine.GetGUIObjectByName("profileHighestRatingText");
this.profileTotalGamesText = Engine.GetGUIObjectByName("profileTotalGamesText");
this.profileWinsText = Engine.GetGUIObjectByName("profileWinsText");
this.profileLossesText = Engine.GetGUIObjectByName("profileLossesText");
this.profileRatioText = Engine.GetGUIObjectByName("profileRatioText");
this.profileErrorText = Engine.GetGUIObjectByName("profileErrorText");
this.profileWindowArea = Engine.GetGUIObjectByName("profileWindowArea");
xmppMessages.registerXmppMessageHandler("game", "profile", this.onProfile.bind(this));
}
registerClosePageHandler(handler)
{
this.closePageHandlers.add(handler);
}
openPage()
{
this.profilePage.hidden = false;
Engine.SetGlobalHotkey("cancel", this.onPressClose.bind(this));
}
onPressLookup()
{
this.requestedPlayer = this.fetchInput.caption;
Engine.SendGetProfile(this.requestedPlayer);
}
onPressClose()
{
this.profilePage.hidden = true;
for (let handler of this.closePageHandlers)
handler();
}
onProfile()
{
let attributes = Engine.GetProfile()[0];
if (this.profilePage.hidden || this.requestedPlayer != attributes.player)
return;
let profileFound = attributes.rating != "-2";
this.profileWindowArea.hidden = !profileFound;
this.profileErrorText.hidden = profileFound;
if (!profileFound)
{
this.profileErrorText.caption =
sprintf(translate("Player \"%(nick)s\" not found."), {
"nick": escapeText(attributes.player)
});
return;
}
this.profilePlayernameText.caption = escapeText(attributes.player);
this.profileRankText.caption = attributes.rank;
this.profileHighestRatingText.caption = attributes.highestRating;
this.profileTotalGamesText.caption = attributes.totalGamesPlayed;
this.profileWinsText.caption = attributes.wins;
this.profileLossesText.caption = attributes.losses;
this.profileRatioText.caption = ProfilePanel.FormatWinRate(attributes);
}
}

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<object name="profilePage" hidden="true">
<!-- Translucent black background -->
<object type="image" z="100" sprite="ModernFade"/>
<object type="image" style="ModernDialog" size="50%-224 50%-160 50%+224 50%+160" z="102">
<object style="ModernLabelText" type="text" size="50%-128 -18 50%+128 14">
<translatableAttribute id="caption">Player Profile Lookup</translatableAttribute>
</object>
<object type="text" size="15 25 40% 50" text_align="right" textcolor="white">
<translatableAttribute id="caption">Enter playername:</translatableAttribute>
</object>
<object name="fetchInput" size="40%+10 25 100%-25 50" type="input" style="ModernInput" font="sans-13"/>
<object name="viewProfileButton" type="button" style="ModernButtonRed" size="50%-64 60 50%+64 85">
<translatableAttribute id="caption">View Profile</translatableAttribute>
</object>
<object name="profileWindowPanel" size="25 95 100%-25 100%-60">
<object name="profileWindowBox" type="image" sprite="ModernDarkBoxGold">
<object name="profileWindowArea" hidden="true">
<object name="profilePlayernameText" size="0 0 100% 25" type="text" style="ModernLabelText" font="sans-bold-16" />
<object size="0 30 40%+40 50" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Current Rank:</translatableAttribute>
</object>
<object name="profileRankText" size="40%+45 30 100% 50" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 50 40%+40 70" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Highest Rating:</translatableAttribute>
</object>
<object name="profileHighestRatingText" size="40%+45 50 100% 70" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 70 40%+40 90" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Total Games:</translatableAttribute>
</object>
<object name="profileTotalGamesText" size="40%+45 70 100% 90" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 90 40%+40 110" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Wins:</translatableAttribute>
</object>
<object name="profileWinsText" size="40%+45 90 100% 110" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 110 40%+40 130" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Losses:</translatableAttribute>
</object>
<object name="profileLossesText" size="40%+45 110 100% 130" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 130 40%+40 150" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Win Rate:</translatableAttribute>
</object>
<object name="profileRatioText" size="40%+45 130 100% 150" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
</object>
<object name="profileErrorText" size="25% 25% 75% 75%" type="text" style="ModernLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Please enter a player name.</translatableAttribute>
</object>
</object>
</object>
<object type="button" name="profileBackButton" style="ModernButtonRed" size="50%-64 100%-50 50%+64 100%-25" hotkey="cancel">
<translatableAttribute id="caption">Back</translatableAttribute>
</object>
</object>
</object>

View File

@ -0,0 +1,115 @@
/**
* This class stores and triggers the event handlers for the GUI messages constructed by the XmppClient.
*/
class XmppMessages
{
constructor()
{
this.xmppMessageHandlers = {};
for (let type in this.MessageTypes)
{
this.xmppMessageHandlers[type] = {};
for (let level of this.MessageTypes[type])
this.xmppMessageHandlers[type][level] = new Set();
}
this.messageBatchProcessedHandlers = new Set();
this.playerListUpdateHandlers = new Set();
Engine.GetGUIObjectByName("lobbyPage").onTick = this.onTick.bind(this);
}
registerXmppMessageHandler(type, level, handler)
{
this.xmppMessageHandlers[type][level].add(handler);
}
unregisterXmppMessageHandler(type, level, handler)
{
this.xmppMessageHandlers[type][level].delete(handler);
}
registerMessageBatchProcessedHandler(handler)
{
this.messageBatchProcessedHandlers.add(handler);
}
unregisterMessageBatchProcessedHandler(handler)
{
this.messageBatchProcessedHandlers.delete(handler);
}
registerPlayerListUpdateHandler(handler)
{
this.playerListUpdateHandlers.add(handler);
}
unregisterPlayerListUpdateHandler(handler)
{
this.playerListUpdateHandlers.delete(handler);
}
onTick()
{
this.handleMessages(Engine.LobbyGuiPollNewMessages);
if (Engine.LobbyGuiPollHasPlayerListUpdate())
for (let handler of this.playerListUpdateHandlers)
handler();
}
processHistoricMessages()
{
this.handleMessages(Engine.LobbyGuiPollHistoricMessages);
}
handleMessages(getMessages)
{
let messages = getMessages();
if (!messages)
return;
for (let message of messages)
{
if (!this.xmppMessageHandlers[message.type])
error("Unrecognized message type: " + message.type);
else if (!this.xmppMessageHandlers[message.type][message.level])
error("Unrecognized message level: " + message.level);
else
for (let handler of this.xmppMessageHandlers[message.type][message.level])
handler(message);
}
for (let handler of this.messageBatchProcessedHandlers)
handler();
}
}
/**
* Processing of notifications sent by XmppClient.cpp.
*/
XmppMessages.prototype.MessageTypes = {
"system": [
"registered",
"connected",
"disconnected",
"error"
],
"chat": [
"subject",
"join",
"leave",
"role",
"nick",
"kicked",
"banned",
"room-message",
"private-message"
],
"game": [
"gamelist",
"profile",
"leaderboard",
"ratinglist"
]
};

File diff suppressed because it is too large Load Diff

View File

@ -4,17 +4,21 @@
<script directory="gui/common/"/>
<script directory="gui/lobby/"/>
<script directory="gui/lobby/LeaderboardPage/"/>
<script directory="gui/lobby/LobbyPage/"/>
<script directory="gui/lobby/LobbyPage/Buttons/"/>
<script directory="gui/lobby/LobbyPage/Chat/"/>
<script directory="gui/lobby/LobbyPage/Chat/ChatMessages/"/>
<script directory="gui/lobby/LobbyPage/Chat/StatusMessages/"/>
<script directory="gui/lobby/LobbyPage/Chat/SystemMessages/"/>
<script directory="gui/lobby/LobbyPage/GameListFilters/"/>
<script directory="gui/lobby/ProfilePage/"/>
<object type="image" sprite="ModernFade" name="fadeImage"/>
<object>
<object type="image" style="ModernWindow" name="lobbyWindow">
<object name="lobbyWindowTitle" style="ModernLabelText" type="text" size="50%-128 0%+4 50%+128 36">
<translatableAttribute id="caption">Multiplayer Lobby</translatableAttribute>
</object>
<include file="gui/lobby/lobby_panels.xml"/>
<include file="gui/lobby/LobbyPage/LobbyPage.xml"/>
<include file="gui/lobby/LeaderboardPage/LeaderboardPage.xml"/>
<include file="gui/lobby/ProfilePage/ProfilePage.xml"/>
</object>
</objects>

View File

@ -1,366 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<object>
<action on="Tick">
onTick();
</action>
<object hotkey="lobby" name="lobbyDialogToggle"/>
<!-- Left panel: Player list. -->
<object name="leftPanel">
<object name="playersBox"
style="ModernSortedList"
selected_column="name"
selected_column_order="1"
type="olist"
sortable="true"
font="sans-bold-stroke-13"
>
<column id="buddy" width="12"/>
<column id="status" width="26%">
<translatableAttribute id="heading">Status</translatableAttribute>
</column>
<column id="name" width="48%-12">
<translatableAttribute id="heading">Name</translatableAttribute>
</column>
<column id="rating" width="26%">
<translatableAttribute id="heading">Rating</translatableAttribute>
</column>
<action on="SelectionChange">
onPlayerListSelection();
</action>
<action on="SelectionColumnChange">
updatePlayerList();
</action>
<action on="mouseleftclickitem">
// In case of clicking on the same player again
selectGameFromPlayername();
</action>
<action on="mouseleftdoubleclickitem">toggleBuddy();</action>
</object>
</object>
<object name="profilePanel" size="20 100%-310 20% 100%-104">
<object name="profileBox" type="image" sprite="ModernDarkBoxGold">
<object name="profileArea" hidden="true">
<object name="usernameText" size="0 0 100% 45" type="text" style="ModernLabelText" font="sans-bold-16" />
<object name="roleText" size="0 45 100% 70" type="text" style="ModernLabelText" font="sans-bold-stroke-12" />
<object size="0 70 40%+40 90" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Current Rank:</translatableAttribute>
</object>
<object name="rankText" size="40%+45 70 100% 90" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 90 40%+40 110" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Highest Rating:</translatableAttribute>
</object>
<object name="highestRatingText" size="40%+45 90 100% 110" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 110 40%+40 130" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Total Games:</translatableAttribute>
</object>
<object name="totalGamesText" size="40%+45 110 100% 130" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 130 40%+40 150" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Wins:</translatableAttribute>
</object>
<object name="winsText" size="40%+45 130 100% 150" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 150 40%+40 170" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Losses:</translatableAttribute>
</object>
<object name="lossesText" size="40%+45 150 100% 170" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 170 40%+40 190" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Win Rate:</translatableAttribute>
</object>
<object name="ratioText" size="40%+45 170 100% 190" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
</object>
</object>
</object>
<object name="leftButtonPanel" size="20 100%-105 20% 100%-20">
<object name="toggleBuddyButton" type="button" style="ModernButtonRed" size="0 100%-79 100% 100%-54">
<action on="Press">
toggleBuddy();
</action>
</object>
<object name="leaderboardButton" type="button" style="ModernButtonRed" size="0 100%-52 100% 100%-27">
<translatableAttribute id="caption">Leaderboard</translatableAttribute>
<action on="Press">
setLeaderboardVisibility(true);
</action>
</object>
<object name="userprofileButton" type="button" style="ModernButtonRed" size="0 100%-25 100% 100%">
<translatableAttribute id="caption">User Profile Lookup</translatableAttribute>
<action on="Press">
setUserProfileVisibility(true);
</action>
</object>
</object>
<!-- Right panel: Game details. -->
<object name="rightPanel">
<object name="gameInfoEmpty" type="image" sprite="ModernDarkBoxGold">
<object name="logo" size="50%-110 40 50%+110 140" type="image" sprite="logo"/>
<object name="subjectBox" type="image" sprite="ModernDarkBoxWhite" size="3% 180 97% 99%">
<object name="subject" size="5 5 100%-5 100%-5" type="text" style="ModernText" text_align="center"/>
</object>
</object>
<object name="gameInfo" type="image" sprite="ModernDarkBoxGold" hidden="true">
<!-- Map Name -->
<object name="sgMapName" size="0 5 100% 20" type="text" style="ModernLabelText"/>
<!-- Map Preview -->
<object name="sgMapPreview" size="5 25 100%-5 190" type="image" sprite=""/>
<object size="5 194 100%-5 195" type="image" sprite="ModernWhiteLine" z="25"/>
<!-- Map Type -->
<object size="5 195 50% 240" type="image" sprite="ModernItemBackShadeLeft">
<object size="0 0 100%-10 100%" type="text" style="ModernRightLabelText">
<translatableAttribute id="caption">Map Type:</translatableAttribute>
</object>
</object>
<object size="50% 195 100%-5 240" type="image" sprite="ModernItemBackShadeRight">
<object name="sgMapType" type="text" style="ModernLeftLabelText"/>
</object>
<object size="5 239 100%-5 240" type="image" sprite="ModernWhiteLine" z="25"/>
<!-- Map Size -->
<object size="5 240 50% 285" type="image" sprite="ModernItemBackShadeLeft">
<object size="0 0 100%-10 100%" type="text" style="ModernRightLabelText">
<translatableAttribute id="caption">Map Size:</translatableAttribute>
</object>
</object>
<object size="50% 240 100%-5 285" type="image" sprite="ModernItemBackShadeRight">
<object name="sgMapSize" type="text" style="ModernLeftLabelText"/>
</object>
<object size="5 284 100%-5 285" type="image" sprite="ModernWhiteLine" z="25"/>
<!-- Map Description -->
<object type="image" sprite="ModernDarkBoxWhite" size="3% 290 97% 65%">
<object name="sgMapDescription" type="text" style="ModernText" font="sans-12"/>
</object>
<object type="image" sprite="ModernDarkBoxWhite" size="3% 65%+5 97% 99%">
<!-- Number of Players -->
<object name="sgNbPlayers" size="3% 3% 97% 3%+15" type="text" style="ModernLabelText"/>
<!-- Game Start Time -->
<object name="sgGameStartTime" size="3%+24 6% 97% 3%+35" type="text" style="ModernLabelText"/>
<!-- Player Names -->
<object name="sgPlayersNames" type="text" style="MapPlayerList"/>
</object>
</object>
<object name="joinGameButton" type="button" style="ModernButtonRed" size="0 100%-79 100% 100%-54" hidden="true">
<translatableAttribute id="caption" comment="Join the game currently selected in the list.">Join Game</translatableAttribute>
<action on="Press">
joinButton();
</action>
</object>
<object name="hostButton" type="button" style="ModernButtonRed" size="0 100%-52 100% 100%-27">
<translatableAttribute id="caption">Host Game</translatableAttribute>
<action on="Press">
hostGame();
</action>
</object>
<object name="leaveButton" type="button" style="ModernButtonRed" size="0 100%-25 100% 100%">
<action on="Press">leaveLobby();</action>
</object>
</object>
<!-- Middle panel: Filters, game list, chat box. -->
<object name="middlePanel">
<object name="gamesBox"
style="ModernSortedList"
selected_column="name"
selected_column_order="1"
type="olist"
sortable="true"
size="0 25 100% 48%"
font="sans-stroke-13"
>
<action on="SelectionChange">updateGameSelection();</action>
<action on="SelectionColumnChange">applyFilters();</action>
<action on="mouseleftdoubleclickitem">joinButton();</action>
<column id="buddy" width="12"/>
<column id="name" color="0 60 0" width="33%-12">
<translatableAttribute id="heading">Name</translatableAttribute>
</column>
<column id="mapName" color="128 128 128" width="25%">
<translatableAttribute id="heading">Map Name</translatableAttribute>
</column>
<column id="mapSize" color="128 128 128" width="16%">
<translatableAttribute id="heading" context="map">Size</translatableAttribute>
</column>
<column id="mapType" color="0 160 160" width="16%">
<translatableAttribute id="heading" context="map">Type</translatableAttribute>
</column>
<column id="nPlayers" color="0 128 128" width="10%">
<translatableAttribute id="heading">Players</translatableAttribute>
</column>
<column id="gameRating" color="0 160 160" width="16%">
<translatableAttribute id="heading">Rating</translatableAttribute>
</column>
</object>
<object name="filterPanel" size="0 0 100% 24">
<object name="mapSizeFilter"
type="dropdown"
style="ModernDropDown"
size="58%-5 0 74%-10 100%"
font="sans-bold-13">
<action on="SelectionChange">applyFilters();</action>
</object>
<object name="mapTypeFilter"
type="dropdown"
style="ModernDropDown"
size="74%-5 0 90%-10 100%"
font="sans-bold-13">
<action on="SelectionChange">applyFilters();</action>
</object>
<object name="playersNumberFilter"
type="dropdown"
style="ModernDropDown"
size="90%-5 0 100%-10 100%"
font="sans-bold-13">
<action on="SelectionChange">applyFilters();</action>
</object>
<object name="gameRatingFilter"
type="dropdown"
style="ModernDropDown"
size="84%-5 0 100%-10 100%"
font="sans-bold-13">
<action on="SelectionChange">applyFilters();</action>
</object>
<object type="text" size="22 0 52%-10 100%" text_align="left" textcolor="white">
<translatableAttribute id="caption">Show only open games</translatableAttribute>
</object>
<object name="filterOpenGames" type="checkbox" checked="false" style="ModernTickBox" size="0 0 20 20">
<action on="Press">
applyFilters();
</action>
</object>
</object>
<object name="chatPanel" size="0 49% 100% 100%" type="image" sprite="ModernDarkBoxGold">
<object name="chatText" size="0 0 100% 100%-28" type="text" style="ChatPanel" font="sans-13"/>
<object name="chatInput" size="0 100%-26 100%-72 100%-4" type="input" style="ModernInput" font="sans-13">
<action on="Press">submitChatInput();</action>
<action on="Tab">
autoCompleteText(this, Engine.GetPlayerList().map(player => player.name));
</action>
</object>
<object size="100%-72 100%-26 100%-4 100%-4" type="button" style="StoneButton">
<translatableAttribute id="caption">Send</translatableAttribute>
<action on="Press">submitChatInput();</action>
</object>
</object>
</object>
<!-- Translucent black background -->
<object hidden="true" name="fade" type="image" z="100" sprite="ModernFade"/>
<!-- Leaderboard -->
<object hidden="true" name="leaderboard" type="image" style="ModernDialog" size="50%-224 50%-160 50%+224 50%+160" z="101">
<object style="ModernLabelText" type="text" size="50%-128 -18 50%+128 14">
<translatableAttribute id="caption">Leaderboard</translatableAttribute>
</object>
<object name="leaderboardBox"
style="ModernList"
type="olist"
size="19 19 100%-19 100%-62">
<column id="rank" color="255 255 255" width="15%">
<translatableAttribute id="heading">Rank</translatableAttribute>
</column>
<column id="name" color="255 255 255" width="55%">
<translatableAttribute id="heading">Name</translatableAttribute>
</column>
<column id="rating" color="255 255 255" width="30%">
<translatableAttribute id="heading">Rating</translatableAttribute>
</column>
<action on="SelectionChange">
lookupSelectedUserProfile(this.name);
</action>
</object>
<object type="button" style="ModernButtonRed" size="50%-133 100%-45 50%-5 100%-17" hotkey="cancel">
<translatableAttribute id="caption">Back</translatableAttribute>
<action on="Press">
setLeaderboardVisibility(false);
</action>
</object>
<object type="button" style="ModernButtonRed" size="50%+5 100%-45 50%+133 100%-17">
<translatableAttribute id="caption">Update</translatableAttribute>
<action on="Press">Engine.SendGetBoardList();</action>
</object>
</object>
<!-- End of leaderboard -->
<!-- User profile lookup -->
<object hidden="true" name="profileFetch" type="image" style="ModernDialog" size="50%-224 50%-160 50%+224 50%+160" z="102">
<object style="ModernLabelText" type="text" size="50%-128 -18 50%+128 14">
<translatableAttribute id="caption">User Profile Lookup</translatableAttribute>
</object>
<object type="text" size="15 25 40% 50" text_align="right" textcolor="white">
<translatableAttribute id="caption">Enter username:</translatableAttribute>
</object>
<object name="fetchInput" size="40%+10 25 100%-25 50" type="input" style="ModernInput" font="sans-13">
<action on="Press">lookupUserProfile();</action>
</object>
<object type="button" style="ModernButtonRed" size="50%-64 60 50%+64 85">
<translatableAttribute id="caption">View Profile</translatableAttribute>
<action on="Press">lookupUserProfile();</action>
</object>
<object name="profileWindowPanel" size="25 95 100%-25 100%-60">
<object name="profileWindowBox" type="image" sprite="ModernDarkBoxGold">
<object name="profileWindowArea" hidden="true">
<object name="profileUsernameText" size="0 0 100% 25" type="text" style="ModernLabelText" font="sans-bold-16" />
<object size="0 30 40%+40 50" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Current Rank:</translatableAttribute>
</object>
<object name="profileRankText" size="40%+45 30 100% 50" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 50 40%+40 70" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Highest Rating:</translatableAttribute>
</object>
<object name="profileHighestRatingText" size="40%+45 50 100% 70" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 70 40%+40 90" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Total Games:</translatableAttribute>
</object>
<object name="profileTotalGamesText" size="40%+45 70 100% 90" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 90 40%+40 110" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Wins:</translatableAttribute>
</object>
<object name="profileWinsText" size="40%+45 90 100% 110" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 110 40%+40 130" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Losses:</translatableAttribute>
</object>
<object name="profileLossesText" size="40%+45 110 100% 130" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
<object size="0 130 40%+40 150" type="text" style="ModernRightLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Win Rate:</translatableAttribute>
</object>
<object name="profileRatioText" size="40%+45 130 100% 150" type="text" style="ModernLeftLabelText" font="sans-bold-stroke-12" />
</object>
<object name="profileErrorText" size="25% 25% 75% 75%" type="text" style="ModernLabelText" font="sans-bold-stroke-13">
<translatableAttribute id="caption">Please enter a player name.</translatableAttribute>
</object>
</object>
</object>
<object type="button" style="ModernButtonRed" size="50%-64 100%-50 50%+64 100%-25" hotkey="cancel">
<translatableAttribute id="caption">Back</translatableAttribute>
<action on="Press">
setUserProfileVisibility(false);
</action>
</object>
</object>
<!-- End of user profile lookup -->
<!-- Keep this hotkey at the end of the gui, because the other cancel hotkeys must be processed first! -->
<object hotkey="cancel" name="cancelDialog"/>
</object>

View File

@ -4,9 +4,11 @@
<include>common/modern/styles.xml</include>
<include>common/modern/sprites.xml</include>
<include>common/global.xml</include>
<include>common/styles.xml</include>
<include>common/sprites.xml</include>
<include>lobby/lobby.xml</include>
<include>common/global.xml</include>
</page>

View File

@ -10,19 +10,20 @@ var g_LobbyMessages = {
}
};
/**
* Other message types (such as gamelists) may be received in case of the current player being logged in and
* logging in in a second program instance with the same account name.
* Therefore messages without handlers are ignored without reporting them here.
*/
function onTick()
{
while (true)
{
let message = Engine.LobbyGuiPollNewMessage();
if (!message)
break;
let messages = Engine.LobbyGuiPollNewMessages();
if (!messages)
return;
for (let message of messages)
if (message.type == "system" && message.level)
g_LobbyMessages[message.level](message);
else
warn("Unknown prelobby message: " + uneval(message));
}
}
function setFeedback(feedbackText)

View File

@ -49,13 +49,14 @@ public:
virtual void SetPresence(const std::string& presence) = 0;
virtual const char* GetPresence(const std::string& nickname) = 0;
virtual const char* GetRole(const std::string& nickname) = 0;
virtual std::wstring GetRating(const std::string& nickname) = 0;
virtual const std::wstring& GetSubject() = 0;
virtual void GUIGetPlayerList(const ScriptInterface& scriptInterface, JS::MutableHandleValue ret) = 0;
virtual void GUIGetGameList(const ScriptInterface& scriptInterface, JS::MutableHandleValue ret) = 0;
virtual void GUIGetBoardList(const ScriptInterface& scriptInterface, JS::MutableHandleValue ret) = 0;
virtual void GUIGetProfile(const ScriptInterface& scriptInterface, JS::MutableHandleValue ret) = 0;
virtual JS::Value GuiPollNewMessage(const ScriptInterface& scriptInterface) = 0;
virtual JS::Value GuiPollNewMessages(const ScriptInterface& scriptInterface) = 0;
virtual JS::Value GuiPollHistoricMessages(const ScriptInterface& scriptInterface) = 0;
virtual bool GuiPollHasPlayerListUpdate() = 0;

View File

@ -573,7 +573,7 @@ void XmppClient::GUIGetGameList(const ScriptInterface& scriptInterface, JS::Muta
const char* stats[] = { "name", "ip", "port", "stunIP", "stunPort", "hostUsername", "state",
"nbp", "maxnbp", "players", "mapName", "niceMapName", "mapSize", "mapType",
"victoryCondition", "startTime", "mods" };
"victoryConditions", "startTime", "mods" };
for(const glooxwrapper::Tag* const& t : m_GameList)
{
@ -687,48 +687,79 @@ void XmppClient::CreateGUIMessage(
bool XmppClient::GuiPollHasPlayerListUpdate()
{
// The initial playerlist will be received in multiple messages
// Only inform the GUI after all of these playerlist fragments were received.
if (!m_initialLoadComplete)
return false;
bool hasUpdate = m_PlayerMapUpdate;
m_PlayerMapUpdate = false;
return hasUpdate;
}
JS::Value XmppClient::GuiPollNewMessage(const ScriptInterface& scriptInterface)
JS::Value XmppClient::GuiPollNewMessages(const ScriptInterface& scriptInterface)
{
if (m_GuiMessageQueue.empty())
if (!m_initialLoadComplete || m_GuiMessageQueue.empty())
return JS::UndefinedValue();
JSContext* cx = scriptInterface.GetContext();
JSAutoRequest rq(cx);
JS::RootedValue message(cx, m_GuiMessageQueue.front());
m_GuiMessageQueue.pop_front();
// Optimize for batch message processing that is more
// performance demanding than processing a lone message.
JS::RootedValue messages(cx);
ScriptInterface::CreateArray(cx, &messages);
JS::RootedValue messageCopy(cx);
if (JS_StructuredClone(cx, message, &messageCopy, nullptr, nullptr))
int j = 0;
for (const JS::Heap<JS::Value>& message : m_GuiMessageQueue)
{
scriptInterface.SetProperty(messageCopy, "historic", true);
scriptInterface.FreezeObject(messageCopy, true);
m_HistoricGuiMessages.push_back(JS::Heap<JS::Value>(messageCopy));
scriptInterface.SetPropertyInt(messages, j++, message);
// Store historic chat messages.
// Only store relevant messages to minimize memory footprint.
JS::RootedValue rootedMessage(cx, message);
std::string type;
scriptInterface.GetProperty(rootedMessage, "type", type);
if (type != "chat")
continue;
std::string level;
scriptInterface.GetProperty(rootedMessage, "level", level);
if (level != "room-message" && level != "private-message")
continue;
JS::RootedValue historicMessage(cx);
if (JS_StructuredClone(cx, rootedMessage, &historicMessage, nullptr, nullptr))
{
scriptInterface.SetProperty(historicMessage, "historic", true);
scriptInterface.FreezeObject(historicMessage, true);
m_HistoricGuiMessages.push_back(JS::Heap<JS::Value>(historicMessage));
}
else
LOGERROR("Could not clone historic lobby GUI message!");
}
return message;
m_GuiMessageQueue.clear();
return messages;
}
JS::Value XmppClient::GuiPollHistoricMessages(const ScriptInterface& scriptInterface)
{
if (m_HistoricGuiMessages.empty())
return JS::UndefinedValue();
JSContext* cx = scriptInterface.GetContext();
JSAutoRequest rq(cx);
JS::RootedValue ret(cx);
ScriptInterface::CreateArray(cx, &ret);
JS::RootedValue messages(cx);
ScriptInterface::CreateArray(cx, &messages);
int j = 0;
for (const JS::Heap<JS::Value>& message : m_HistoricGuiMessages)
scriptInterface.SetPropertyInt(ret, j++, message);
scriptInterface.SetPropertyInt(messages, j++, message);
return ret;
return messages;
}
/**
@ -1087,6 +1118,20 @@ const char* XmppClient::GetRole(const std::string& nick)
return GetRoleString(it->second.m_Role);
}
/**
* Get the most recent received rating of the given nick.
* Notice that this doesn't request a rating profile if it hasn't been received yet.
*/
std::wstring XmppClient::GetRating(const std::string& nick)
{
const PlayerMap::iterator it = m_PlayerMap.find(nick);
if (it == m_PlayerMap.end())
return std::wstring();
return wstring_from_utf8(it->second.m_Rating.to_string());
}
/*****************************************************
* Utilities *
*****************************************************/

View File

@ -90,6 +90,7 @@ public:
void SetPresence(const std::string& presence);
const char* GetPresence(const std::string& nickname);
const char* GetRole(const std::string& nickname);
std::wstring GetRating(const std::string& nickname);
const std::wstring& GetSubject();
void GUIGetPlayerList(const ScriptInterface& scriptInterface, JS::MutableHandleValue ret);
@ -150,7 +151,7 @@ protected:
virtual void handleSessionInitiation(glooxwrapper::Jingle::Session& session, const glooxwrapper::Jingle::Session::Jingle& jingle);
public:
JS::Value GuiPollNewMessage(const ScriptInterface& scriptInterface);
JS::Value GuiPollNewMessages(const ScriptInterface& scriptInterface);
JS::Value GuiPollHistoricMessages(const ScriptInterface& scriptInterface);
bool GuiPollHasPlayerListUpdate();
void SendMUCMessage(const std::string& message);

View File

@ -56,7 +56,7 @@ void JSI_Lobby::RegisterScriptFunctions(const ScriptInterface& scriptInterface)
scriptInterface.RegisterFunction<JS::Value, &JSI_Lobby::GetGameList>("GetGameList");
scriptInterface.RegisterFunction<JS::Value, &JSI_Lobby::GetBoardList>("GetBoardList");
scriptInterface.RegisterFunction<JS::Value, &JSI_Lobby::GetProfile>("GetProfile");
scriptInterface.RegisterFunction<JS::Value, &JSI_Lobby::LobbyGuiPollNewMessage>("LobbyGuiPollNewMessage");
scriptInterface.RegisterFunction<JS::Value, &JSI_Lobby::LobbyGuiPollNewMessages>("LobbyGuiPollNewMessages");
scriptInterface.RegisterFunction<JS::Value, &JSI_Lobby::LobbyGuiPollHistoricMessages>("LobbyGuiPollHistoricMessages");
scriptInterface.RegisterFunction<bool, &JSI_Lobby::LobbyGuiPollHasPlayerListUpdate>("LobbyGuiPollHasPlayerListUpdate");
scriptInterface.RegisterFunction<void, std::wstring, &JSI_Lobby::LobbySendMessage>("LobbySendMessage");
@ -67,6 +67,7 @@ void JSI_Lobby::RegisterScriptFunctions(const ScriptInterface& scriptInterface)
scriptInterface.RegisterFunction<void, std::wstring, std::wstring, &JSI_Lobby::LobbyBan>("LobbyBan");
scriptInterface.RegisterFunction<const char*, std::wstring, &JSI_Lobby::LobbyGetPlayerPresence>("LobbyGetPlayerPresence");
scriptInterface.RegisterFunction<const char*, std::wstring, &JSI_Lobby::LobbyGetPlayerRole>("LobbyGetPlayerRole");
scriptInterface.RegisterFunction<std::wstring, std::wstring, &JSI_Lobby::LobbyGetPlayerRating>("LobbyGetPlayerRating");
scriptInterface.RegisterFunction<std::wstring, std::wstring, std::wstring, &JSI_Lobby::EncryptPassword>("EncryptPassword");
scriptInterface.RegisterFunction<std::wstring, &JSI_Lobby::LobbyGetRoomSubject>("LobbyGetRoomSubject");
#endif // CONFIG2_LOBBY
@ -243,12 +244,12 @@ bool JSI_Lobby::LobbyGuiPollHasPlayerListUpdate(ScriptInterface::CxPrivate* UNUS
return g_XmppClient && g_XmppClient->GuiPollHasPlayerListUpdate();
}
JS::Value JSI_Lobby::LobbyGuiPollNewMessage(ScriptInterface::CxPrivate* pCxPrivate)
JS::Value JSI_Lobby::LobbyGuiPollNewMessages(ScriptInterface::CxPrivate* pCxPrivate)
{
if (!g_XmppClient)
return JS::UndefinedValue();
return g_XmppClient->GuiPollNewMessage(*(pCxPrivate->pScriptInterface));
return g_XmppClient->GuiPollNewMessages(*(pCxPrivate->pScriptInterface));
}
JS::Value JSI_Lobby::LobbyGuiPollHistoricMessages(ScriptInterface::CxPrivate* pCxPrivate)
@ -325,6 +326,14 @@ const char* JSI_Lobby::LobbyGetPlayerRole(ScriptInterface::CxPrivate* UNUSED(pCx
return g_XmppClient->GetRole(utf8_from_wstring(nickname));
}
std::wstring JSI_Lobby::LobbyGetPlayerRating(ScriptInterface::CxPrivate* UNUSED(pCxPrivate), const std::wstring& nickname)
{
if (!g_XmppClient)
return std::wstring();
return g_XmppClient->GetRating(utf8_from_wstring(nickname));
}
// Non-public secure PBKDF2 hash function with salting and 1,337 iterations
//
// TODO: We should use libsodium's crypto_pwhash instead of this. The first reason is that

View File

@ -48,7 +48,7 @@ namespace JSI_Lobby
JS::Value GetGameList(ScriptInterface::CxPrivate* pCxPrivate);
JS::Value GetBoardList(ScriptInterface::CxPrivate* pCxPrivate);
JS::Value GetProfile(ScriptInterface::CxPrivate* pCxPrivate);
JS::Value LobbyGuiPollNewMessage(ScriptInterface::CxPrivate* pCxPrivate);
JS::Value LobbyGuiPollNewMessages(ScriptInterface::CxPrivate* pCxPrivate);
JS::Value LobbyGuiPollHistoricMessages(ScriptInterface::CxPrivate* pCxPrivate);
bool LobbyGuiPollHasPlayerListUpdate(ScriptInterface::CxPrivate* pCxPrivate);
void LobbySendMessage(ScriptInterface::CxPrivate* pCxPrivate, const std::wstring& message);
@ -59,6 +59,7 @@ namespace JSI_Lobby
void LobbyBan(ScriptInterface::CxPrivate* pCxPrivate, const std::wstring& nick, const std::wstring& reason);
const char* LobbyGetPlayerPresence(ScriptInterface::CxPrivate* pCxPrivate, const std::wstring& nickname);
const char* LobbyGetPlayerRole(ScriptInterface::CxPrivate* pCxPrivate, const std::wstring& nickname);
std::wstring LobbyGetPlayerRating(ScriptInterface::CxPrivate* pCxPrivate, const std::wstring& nickname);
std::wstring LobbyGetRoomSubject(ScriptInterface::CxPrivate* pCxPrivate);
// Non-public secure PBKDF2 hash function with salting and 1,337 iterations