Index: ps/trunk/binaries/data/mods/public/gui/common/functions_utility.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/common/functions_utility.js (revision 17050) +++ ps/trunk/binaries/data/mods/public/gui/common/functions_utility.js (revision 17051) @@ -1,272 +1,304 @@ /* DESCRIPTION : Generic utility functions. NOTES : */ // ==================================================================== function getRandom(randomMin, randomMax) { // Returns a random whole number in a min..max range. // NOTE: There should probably be an engine function for this, // since we'd need to keep track of random seeds for replays. var randomNum = randomMin + (randomMax-randomMin)*Math.random(); // num is random, from A to B return Math.round(randomNum); } // ==================================================================== // Get list of XML files in pathname with recursion, excepting those starting with _ function getXMLFileList(pathname) { var files = Engine.BuildDirEntList(pathname, "*.xml", true); var result = []; // Get only subpath from filename and discard extension for (var i = 0; i < files.length; ++i) { var file = files[i]; file = file.substring(pathname.length, file.length-4); // Split path into directories so we can check for beginning _ character var tokens = file.split("/"); if (tokens[tokens.length-1][0] != "_") result.push(file); } return result; } // ==================================================================== // Get list of JSON files in pathname function getJSONFileList(pathname) { var files = Engine.BuildDirEntList(pathname, "*.json", false); // Remove the path and extension from each name, since we just want the filename files = [ n.substring(pathname.length, n.length-5) for each (n in files) ]; return files; } // ==================================================================== // A sorting function for arrays of objects with 'name' properties, ignoring case function sortNameIgnoreCase(x, y) { var lowerX = x.name.toLowerCase(); var lowerY = y.name.toLowerCase(); if (lowerX < lowerY) return -1; else if (lowerX > lowerY) return 1; else return 0; } // ==================================================================== /** * Escape tag start and escape characters, so users cannot use special formatting. * Also limit string length to 256 characters (not counting escape characters). */ function escapeText(text) { if (!text) return text; return text.substr(0, 255).replace(/\\/g, "\\\\").replace(/\[/g, "\\["); } // ==================================================================== // Load default player data, for when it's not otherwise specified function initPlayerDefaults() { var data = Engine.ReadJSONFile("simulation/data/player_defaults.json"); if (!data || !data.PlayerData) { error("Failed to parse player defaults in player_defaults.json (check for valid JSON data)"); return []; } return data.PlayerData; } // ==================================================================== // Load map size data function initMapSizes() { var sizes = { "shortNames":[], "names":[], "tiles": [], "default": 0 }; var data = Engine.ReadJSONFile("simulation/data/map_sizes.json"); if (!data || !data.Sizes) { error("Failed to parse map sizes in map_sizes.json (check for valid JSON data)"); return sizes; } translateObjectKeys(data, ["Name", "LongName"]); for (var i = 0; i < data.Sizes.length; ++i) { sizes.shortNames.push(data.Sizes[i].Name); sizes.names.push(data.Sizes[i].LongName); sizes.tiles.push(data.Sizes[i].Tiles); if (data.Sizes[i].Default) sizes["default"] = i; } return sizes; } +/** + * Returns title or placeholder. Requires g_mapSizes. + * + * @param mapSize {Number} - tilecount + */ +function translateMapSize(tiles) +{ + var idx = g_mapSizes.tiles.indexOf(+tiles); + return (idx == -1) ? translateWithContext("map size", "Default") : g_mapSizes.shortNames[idx]; +} + +/** + * Returns map description and preview image or placeholder. + */ +function getMapDescriptionAndPreview(mapType, mapName) +{ + var mapData; + if (mapType == "random" && mapName == "random") + mapData = { "settings": { "Description": translate("A randomly selected map.") } }; + else if (mapType == "random" && Engine.FileExists(mapName + ".json")) + mapData = Engine.ReadJSONFile(mapName + ".json"); + else if (Engine.FileExists(mapName + ".xml")) + mapData = Engine.LoadMapSettings(mapName + ".xml"); + else + warn(sprintf("Map '%(mapName)s' not found locally.", { "mapName": mapName })); + + return { + "description": mapData && mapData.settings && mapData.settings.Description ? translate(mapData.settings.Description) : translate("Sorry, no description available."), + "preview": mapData && mapData.settings && mapData.settings.Preview ? mapData.settings.Preview : "nopreview.png" + }; +} + // ==================================================================== // Convert integer color values to string (for use in GUI objects) function rgbToGuiColor(color, alpha) { var ret; if (color && ("r" in color) && ("g" in color) && ("b" in color)) ret = color.r + " " + color.g + " " + color.b; else ret = "0 0 0"; if (alpha) ret += " " + alpha; return ret; } function sameColor(color1, color2) { return color1.r === color2.r && color1.g === color2.g && color1.b === color2.b; } /** * Computes the euclidian distance between the two colors. * The smaller the return value, the close the colors. Zero if identical. */ function colorDistance(color1, color2) { return Math.sqrt(Math.pow(color2.r - color1.r, 2) + Math.pow(color2.g - color1.g, 2) + Math.pow(color2.b - color1.b, 2)); } // ==================================================================== /** * Convert time in milliseconds to [hh:]mm:ss string representation. * @param time Time period in milliseconds (integer) * @return String representing time period */ function timeToString(time) { if (time < 1000 * 60 * 60) var format = translate("mm:ss"); else var format = translate("HH:mm:ss"); return Engine.FormatMillisecondsIntoDateString(time, format); } // ==================================================================== function removeDupes(array) { // loop backwards to make splice operations cheaper var i = array.length; while (i--) { if (array.indexOf(array[i]) != i) array.splice(i, 1); } } // ==================================================================== // "Inside-out" implementation of Fisher-Yates shuffle function shuffleArray(source) { if (!source.length) return []; var result = [source[0]]; for (var i = 1; i < source.length; ++i) { var j = Math.floor(Math.random() * i); result[i] = result[j]; result[j] = source[i]; } return result; } // ==================================================================== // Filter out conflicting characters and limit the length of a given name. // @param name Name to be filtered. // @param stripUnicode Whether or not to remove unicode characters. // @param stripSpaces Whether or not to remove whitespace. function sanitizePlayerName(name, stripUnicode, stripSpaces) { // We delete the '[', ']' characters (GUI tags) and delete the ',' characters (player name separators) by default. var sanitizedName = name.replace(/[\[\],]/g, ""); // Optionally strip unicode if (stripUnicode) sanitizedName = sanitizedName.replace(/[^\x20-\x7f]/g, ""); // Optionally strip whitespace if (stripSpaces) sanitizedName = sanitizedName.replace(/\s/g, ""); // Limit the length to 20 characters return sanitizedName.substr(0,20); } function tryAutoComplete(text, autoCompleteList) { if (!text.length) return text; var wordSplit = text.split(/\s/g); if (!wordSplit.length) return text; var lastWord = wordSplit.pop(); if (!lastWord.length) return text; for (var word of autoCompleteList) { if (word.toLowerCase().indexOf(lastWord.toLowerCase()) != 0) continue; text = wordSplit.join(" "); if (text.length > 0) text += " "; text += word; break; } return text; } function autoCompleteNick(guiName, playerList) { var input = Engine.GetGUIObjectByName(guiName); var text = input.caption; if (!text.length) return; var autoCompleteList = []; for (var player of playerList) autoCompleteList.push(player.name); var bufferPosition = input.buffer_position; var textTillBufferPosition = text.substring(0, bufferPosition); var newText = tryAutoComplete(textTillBufferPosition, autoCompleteList); input.caption = newText + text.substring(bufferPosition); input.buffer_position = bufferPosition + (newText.length - textTillBufferPosition.length); } Index: ps/trunk/binaries/data/mods/public/gui/lobby/lobby.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/lobby/lobby.js (revision 17050) +++ ps/trunk/binaries/data/mods/public/gui/lobby/lobby.js (revision 17051) @@ -1,1182 +1,1155 @@ const g_MapTypes = prepareForDropdown(g_Settings ? g_Settings.MapTypes : undefined); var g_ChatMessages = []; var g_Name = "unknown"; var g_GameList = {} var g_GameListSortBy = "name"; var g_PlayerListSortBy = "name"; var g_GameListOrder = 1; // 1 for ascending sort, and -1 for descending var g_PlayerListOrder = 1; var g_specialKey = Math.random(); // This object looks like {"name":[numMessagesSinceReset, lastReset, timeBlocked]} when in use. var g_spamMonitor = {}; var g_timestamp = Engine.ConfigDB_GetValue("user", "lobby.chattimestamp") == "true"; var g_mapSizes = {}; var g_userRating = ""; // Rating of user, defaults to Unrated var g_modPrefix = "@"; // Block spammers for 30 seconds. var SPAM_BLOCK_LENGTH = 30; //////////////////////////////////////////////////////////////////////////////////////////////// function init(attribs) { if (!g_Settings) { returnToMainMenu(); return; } // Play menu music initMusic(); global.music.setState(global.music.states.MENU); g_Name = Engine.LobbyGetNick(); g_mapSizes = initMapSizes(); g_mapSizes.shortNames.splice(0, 0, translateWithContext("map size", "Any")); g_mapSizes.tiles.splice(0, 0, ""); var mapSizeFilter = Engine.GetGUIObjectByName("mapSizeFilter"); mapSizeFilter.list = g_mapSizes.shortNames; mapSizeFilter.list_data = g_mapSizes.tiles; // Setup number-of-players filter var playersArray = Array(g_MaxPlayers).fill(0).map((v, i) => i + 1); // 1, 2, ... MaxPlayers var playersNumberFilter = Engine.GetGUIObjectByName("playersNumberFilter"); playersNumberFilter.list = [translateWithContext("player number", "Any")].concat(playersArray); playersNumberFilter.list_data = [""].concat(playersArray); var mapTypeFilter = Engine.GetGUIObjectByName("mapTypeFilter"); mapTypeFilter.list = [translateWithContext("map", "Any")].concat(g_MapTypes.Title); mapTypeFilter.list_data = [""].concat(g_MapTypes.Name); Engine.LobbySetPlayerPresence("available"); Engine.SendGetGameList(); Engine.SendGetBoardList(); // When rejoining the lobby after a game, we don't need to process presence changes Engine.LobbyClearPresenceUpdates(); updatePlayerList(); updateSubject(Engine.LobbyGetRoomSubject()); resetFilters(); } function returnToMainMenu() { lobbyStop(); Engine.SwitchGuiPage("page_pregame.xml"); } //////////////////////////////////////////////////////////////////////////////////////////////// // Xmpp client connection management //////////////////////////////////////////////////////////////////////////////////////////////// function lobbyStop() { Engine.StopXmppClient(); } function lobbyConnect() { Engine.ConnectXmppClient(); } function lobbyDisconnect() { Engine.DisconnectXmppClient(); } //////////////////////////////////////////////////////////////////////////////////////////////// // Update functions //////////////////////////////////////////////////////////////////////////////////////////////// function updateGameListOrderSelection() { g_GameListSortBy = Engine.GetGUIObjectByName("gamesBox").selected_column; g_GameListOrder = Engine.GetGUIObjectByName("gamesBox").selected_column_order; applyFilters(); } function updatePlayerListOrderSelection() { g_PlayerListSortBy = Engine.GetGUIObjectByName("playersBox").selected_column; g_PlayerListOrder = Engine.GetGUIObjectByName("playersBox").selected_column_order; updatePlayerList(); } function resetFilters() { // Reset states of gui objects Engine.GetGUIObjectByName("mapSizeFilter").selected = 0 Engine.GetGUIObjectByName("playersNumberFilter").selected = 0; Engine.GetGUIObjectByName("mapTypeFilter").selected = g_MapTypes.Default; Engine.GetGUIObjectByName("showFullFilter").checked = false; applyFilters(); } function applyFilters() { // Update the list of games updateGameList(); // Update info box about the game currently selected updateGameSelection(); } /** * Filter a game based on the status of the filter dropdowns. * * @param game Game to be tested. * @return True if game should not be displayed. */ function filterGame(game) { var mapSizeFilter = Engine.GetGUIObjectByName("mapSizeFilter"); var playersNumberFilter = Engine.GetGUIObjectByName("playersNumberFilter"); var mapTypeFilter = Engine.GetGUIObjectByName("mapTypeFilter"); var showFullFilter = Engine.GetGUIObjectByName("showFullFilter"); // We assume index 0 means display all for any given filter. if (mapSizeFilter.selected != 0 && game.mapSize != mapSizeFilter.list_data[mapSizeFilter.selected]) return true; if (playersNumberFilter.selected != 0 && game.tnbp != playersNumberFilter.list_data[playersNumberFilter.selected]) return true; if (mapTypeFilter.selected != 0 && game.mapType != mapTypeFilter.list_data[mapTypeFilter.selected]) return true; if (!showFullFilter.checked && game.tnbp <= game.nbp) return true; return false; } /** * Update the subject GUI object. * * @param newSubject New room subject. */ function updateSubject(newSubject) { var subject = Engine.GetGUIObjectByName("subject"); var subjectBox = Engine.GetGUIObjectByName("subjectBox"); var logo = Engine.GetGUIObjectByName("logo"); // Load new subject and un-escape newlines. subject.caption = newSubject; // If the subject is only whitespace, hide it and reposition the logo. if (subject.caption.match(/^([\s\t\r\n]*)$/g)) { subjectBox.hidden = true; logo.size = "50%-110 50%-50 50%+110 50%+50"; } else { subjectBox.hidden = false; logo.size = "50%-110 40 50%+110 140"; } } /** * Do a full update of the player listing, including ratings from cached C++ information. * * @return Array containing the player, presence, nickname, and rating listings. */ function updatePlayerList() { var playersBox = Engine.GetGUIObjectByName("playersBox"); var playerList = []; var presenceList = []; var nickList = []; var ratingList = []; var cleanPlayerList = Engine.GetPlayerList(); // Sort the player list, ignoring case. cleanPlayerList.sort(function(a,b) { switch (g_PlayerListSortBy) { case 'rating': if (a.rating < b.rating) return -g_PlayerListOrder; else if (a.rating > b.rating) return g_PlayerListOrder; return 0; case 'status': let order = ["available", "away", "playing", "gone", "offline"]; let presenceA = order.indexOf(a.presence); let presenceB = order.indexOf(b.presence); if (presenceA < presenceB) return -g_PlayerListOrder; else if (presenceA > presenceB) return g_PlayerListOrder; return 0; case 'name': default: var aName = a.name.toLowerCase(); var bName = b.name.toLowerCase(); if (aName < bName) return -g_PlayerListOrder; else if (aName > bName) return g_PlayerListOrder; return 0; } }); for (var i = 0; i < cleanPlayerList.length; i++) { // Identify current user's rating. if (cleanPlayerList[i].name == g_Name && cleanPlayerList[i].rating) g_userRating = cleanPlayerList[i].rating; // Add a "-" for unrated players. if (!cleanPlayerList[i].rating) cleanPlayerList[i].rating = "-"; // Colorize. var [name, status, rating] = formatPlayerListEntry(cleanPlayerList[i].name, cleanPlayerList[i].presence, cleanPlayerList[i].rating, cleanPlayerList[i].role); // Push to lists. playerList.push(name); presenceList.push(status); nickList.push(cleanPlayerList[i].name); var ratingSpaces = " "; for (var index = 0; index < 4 - Math.ceil(Math.log(cleanPlayerList[i].rating) / Math.LN10); index++) ratingSpaces += " "; ratingList.push(String(ratingSpaces + rating)); } playersBox.list_name = playerList; playersBox.list_status = presenceList; playersBox.list_rating = ratingList; playersBox.list = nickList; if (playersBox.selected >= playersBox.list.length) playersBox.selected = -1; } /** * Display the profile of the selected player. * Displays N/A for all stats until updateProfile is called when the stats * are actually received from the bot. * * @param caller From which screen is the user requesting data from? */ function displayProfile(caller) { var playerList, rating; if (caller == "leaderboard") playerList = Engine.GetGUIObjectByName("leaderboardBox"); else if (caller == "lobbylist") playerList = Engine.GetGUIObjectByName("playersBox"); else if (caller == "fetch") { Engine.SendGetProfile(Engine.GetGUIObjectByName("fetchInput").caption); return; } else return; if (!playerList.list[playerList.selected]) { Engine.GetGUIObjectByName("profileArea").hidden = true; return; } Engine.GetGUIObjectByName("profileArea").hidden = false; Engine.SendGetProfile(playerList.list[playerList.selected]); var user = playerList.list_name[playerList.selected]; var role = Engine.LobbyGetPlayerRole(playerList.list[playerList.selected]); var userList = Engine.GetGUIObjectByName("playersBox"); if (role && caller == "lobbylist") { // Make the role uppercase. role = role.charAt(0).toUpperCase() + role.slice(1); if (role == "Moderator") role = '[color="0 125 0"]' + translate(role) + '[/color]'; } else role = ""; Engine.GetGUIObjectByName("usernameText").caption = user; Engine.GetGUIObjectByName("roleText").caption = translate(role); Engine.GetGUIObjectByName("rankText").caption = translate("N/A"); Engine.GetGUIObjectByName("highestRatingText").caption = translate("N/A"); Engine.GetGUIObjectByName("totalGamesText").caption = translate("N/A"); Engine.GetGUIObjectByName("winsText").caption = translate("N/A"); Engine.GetGUIObjectByName("lossesText").caption = translate("N/A"); Engine.GetGUIObjectByName("ratioText").caption = translate("N/A"); } /** * Update the profile of the selected player with data from the bot. * */ function updateProfile() { var playerList, user; var attributes = Engine.GetProfile(); if (!Engine.GetGUIObjectByName("profileFetch").hidden) { user = attributes[0].player; if (attributes[0].rating == "-2") // Profile not found code { Engine.GetGUIObjectByName("profileWindowArea").hidden = true; Engine.GetGUIObjectByName("profileErrorText").hidden = false; return; } Engine.GetGUIObjectByName("profileWindowArea").hidden = false; Engine.GetGUIObjectByName("profileErrorText").hidden = true; if (attributes[0].rating != "") user = sprintf(translate("%(nick)s (%(rating)s)"), { nick: user, rating: attributes[0].rating }); Engine.GetGUIObjectByName("profileUsernameText").caption = user; Engine.GetGUIObjectByName("profileRankText").caption = attributes[0].rank; Engine.GetGUIObjectByName("profileHighestRatingText").caption = attributes[0].highestRating; Engine.GetGUIObjectByName("profileTotalGamesText").caption = attributes[0].totalGamesPlayed; Engine.GetGUIObjectByName("profileWinsText").caption = attributes[0].wins; Engine.GetGUIObjectByName("profileLossesText").caption = attributes[0].losses; var winRate = (attributes[0].wins / attributes[0].totalGamesPlayed * 100).toFixed(2); if (attributes[0].totalGamesPlayed != 0) Engine.GetGUIObjectByName("profileRatioText").caption = sprintf(translate("%(percentage)s%%"), { percentage: winRate }); else Engine.GetGUIObjectByName("profileRatioText").caption = translateWithContext("Used for an undefined winning rate", "-"); return; } else if (!Engine.GetGUIObjectByName("leaderboard").hidden) playerList = Engine.GetGUIObjectByName("leaderboardBox"); else playerList = Engine.GetGUIObjectByName("playersBox"); if (attributes[0].rating == "-2") return; // Make sure the stats we have received coincide with the selected player. if (attributes[0].player != playerList.list[playerList.selected]) return; user = playerList.list_name[playerList.selected]; if (attributes[0].rating != "") user = sprintf(translate("%(nick)s (%(rating)s)"), { nick: user, rating: attributes[0].rating }); Engine.GetGUIObjectByName("usernameText").caption = user; Engine.GetGUIObjectByName("rankText").caption = attributes[0].rank; Engine.GetGUIObjectByName("highestRatingText").caption = attributes[0].highestRating; Engine.GetGUIObjectByName("totalGamesText").caption = attributes[0].totalGamesPlayed; Engine.GetGUIObjectByName("winsText").caption = attributes[0].wins; Engine.GetGUIObjectByName("lossesText").caption = attributes[0].losses; var winRate = (attributes[0].wins / attributes[0].totalGamesPlayed * 100).toFixed(2); if (attributes[0].totalGamesPlayed != 0) Engine.GetGUIObjectByName("ratioText").caption = sprintf(translate("%(percentage)s%%"), { percentage: winRate }); else Engine.GetGUIObjectByName("ratioText").caption = translateWithContext("Used for an undefined winning rate", "-"); } /** * Update the leaderboard from data cached in C++. */ function updateLeaderboard() { // Get list from C++ var boardList = Engine.GetBoardList(); // Get GUI leaderboard object var leaderboard = Engine.GetGUIObjectByName("leaderboardBox"); // Sort list in acending order by rating boardList.sort(function(a, b) b.rating - a.rating); var list = []; var list_name = []; var list_rank = []; var list_rating = []; // Push changes for (var i = 0; i < boardList.length; i++) { list_name.push(boardList[i].name); list_rating.push(boardList[i].rating); list_rank.push(i+1); list.push(boardList[i].name); } leaderboard.list_name = list_name; leaderboard.list_rating = list_rating; leaderboard.list_rank = list_rank; leaderboard.list = list; if (leaderboard.selected >= leaderboard.list.length) leaderboard.selected = -1; } /** * Update the game listing from data cached in C++. */ function updateGameList() { var gamesBox = Engine.GetGUIObjectByName("gamesBox"); var gameList = Engine.GetGameList(); // Store the game whole game list data so that we can access it later // to update the game info panel. g_GameList = gameList; // Sort the list of games to that games 'waiting' are displayed at the top, followed by 'init', followed by 'running'. var gameStatuses = ['waiting', 'init', 'running']; g_GameList.sort(function (a,b) { switch (g_GameListSortBy) { case 'name': case 'mapSize': // mapSize contains the number of tiles for random maps // scenario maps always display default size case 'mapType': if (a[g_GameListSortBy] < b[g_GameListSortBy]) return -g_GameListOrder; else if (a[g_GameListSortBy] > b[g_GameListSortBy]) return g_GameListOrder; return 0; case 'mapName': if (translate(a.niceMapName) < translate(b.niceMapName)) return -g_GameListOrder; else if (translate(a.niceMapName) > translate(b.niceMapName)) return g_GameListOrder; return 0; case 'nPlayers': // Numerical comparison of player count ratio. if (a.nbp * b.tnbp < b.nbp * a.tnbp) // ratio a = a.nbp / a.tnbp, ratio b = b.nbp / b.tnbp return -g_GameListOrder; else if (a.nbp * b.tnbp > b.nbp * a.tnbp) return g_GameListOrder; return 0; default: if (gameStatuses.indexOf(a.state) < gameStatuses.indexOf(b.state)) return -1; else if (gameStatuses.indexOf(a.state) > gameStatuses.indexOf(b.state)) return 1; // Alphabetical comparison of names as tiebreaker. if (a.name < b.name) return -1; else if (a.name > b.name) return 1; return 0; } }); var list_name = []; var list_ip = []; var list_mapName = []; var list_mapSize = []; var list_mapType = []; var list_nPlayers = []; var list = []; var list_data = []; var c = 0; for (var g of gameList) { if (!filterGame(g)) { // 'waiting' games are highlighted in orange, 'running' in red, and 'init' in green. let name = escapeText(g.name); if (g.state == 'init') name = '[color="0 125 0"]' + name + '[/color]'; else if (g.state == 'waiting') name = '[color="255 127 0"]' + name + '[/color]'; else name = '[color="255 0 0"]' + name + '[/color]'; list_name.push(name); list_ip.push(g.ip); list_mapName.push(translate(g.niceMapName)); - list_mapSize.push(translatedMapSize(g.mapSize)); + list_mapSize.push(translateMapSize(g.mapSize)); let mapTypeIdx = g_MapTypes.Name.indexOf(g.mapType); list_mapType.push(mapTypeIdx != -1 ? g_MapTypes.Title[mapTypeIdx] : ""); list_nPlayers.push(g.nbp + "/" +g.tnbp); list.push(name); list_data.push(c); } c++; } gamesBox.list_name = list_name; gamesBox.list_mapName = list_mapName; gamesBox.list_mapSize = list_mapSize; gamesBox.list_mapType = list_mapType; gamesBox.list_nPlayers = list_nPlayers; gamesBox.list = list; gamesBox.list_data = list_data; if (gamesBox.selected >= gamesBox.list_name.length) gamesBox.selected = -1; // Update info box about the game currently selected updateGameSelection(); } /** * Colorize and format the entries in the player list. * * @param nickname Name of player. * @param presence Presence of player. * @param rating Rating of player. * @return Colorized versions of name, status, and rating. */ function formatPlayerListEntry(nickname, presence, rating) { // Set colors based on player status var color; var status; switch (presence) { case "playing": color = "125 0 0"; status = translate("Busy"); break; case "gone": case "away": color = "229 76 13"; status = translate("Away"); break; case "available": color = "0 125 0"; status = translate("Online"); break; case "offline": color = "0 0 0"; status = translate("Offline"); break; default: warn(sprintf("Unknown presence '%(presence)s'", { presence: presence })); color = "178 178 178"; status = translateWithContext("lobby presence", "Unknown"); break; } // Center the unrated symbol. if (rating == "-") rating = " -"; var formattedStatus = '[color="' + color + '"]' + status + "[/color]"; var formattedRating = '[color="' + color + '"]' + rating + "[/color]"; var role = Engine.LobbyGetPlayerRole(nickname); if (role == "moderator") nickname = g_modPrefix + nickname; var formattedName = colorPlayerName(nickname); // Push this player's name and status onto the list return [formattedName, formattedStatus, formattedRating]; } /** - * Given a map size, returns that map size translated into the current - * language. - */ -function translatedMapSize(mapSize) -{ - if (+mapSize !== +mapSize) // NaN - return translate(mapSize); - else - return g_mapSizes.shortNames[g_mapSizes.tiles.indexOf(+mapSize)]; -} - -/** * Populate the game info area with information on the current game selection. */ function updateGameSelection() { var selected = Engine.GetGUIObjectByName("gamesBox").selected; // If a game is not selected, hide the game information area. if (selected == -1) { Engine.GetGUIObjectByName("gameInfo").hidden = true; Engine.GetGUIObjectByName("joinGameButton").hidden = true; Engine.GetGUIObjectByName("gameInfoEmpty").hidden = false; return; } - var mapData; var g = Engine.GetGUIObjectByName("gamesBox").list_data[selected]; - // Load map data - if (g_GameList[g].mapType == "random" && g_GameList[g].mapName == "random") - mapData = {"settings": {"Description": translate("A randomly selected map.")}}; - else if (g_GameList[g].mapType == "random" && Engine.FileExists(g_GameList[g].mapName + ".json")) - mapData = Engine.ReadJSONFile(g_GameList[g].mapName + ".json"); - else if (Engine.FileExists(g_GameList[g].mapName + ".xml")) - mapData = Engine.LoadMapSettings(g_GameList[g].mapName + ".xml"); - else - // Warn the player if we can't find the map. - warn(sprintf("Map '%(mapName)s' not found locally.", { mapName: g_GameList[g].mapName })); - // Show the game info panel and join button. Engine.GetGUIObjectByName("gameInfo").hidden = false; Engine.GetGUIObjectByName("joinGameButton").hidden = false; Engine.GetGUIObjectByName("gameInfoEmpty").hidden = true; // Display the map name, number of players, the names of the players, the map size and the map type. Engine.GetGUIObjectByName("sgMapName").caption = translate(g_GameList[g].niceMapName); Engine.GetGUIObjectByName("sgNbPlayers").caption = g_GameList[g].nbp + "/" + g_GameList[g].tnbp; Engine.GetGUIObjectByName("sgPlayersNames").caption = g_GameList[g].players; - Engine.GetGUIObjectByName("sgMapSize").caption = translatedMapSize(g_GameList[g].mapSize); + Engine.GetGUIObjectByName("sgMapSize").caption = translateMapSize(g_GameList[g].mapSize); let mapTypeIdx = g_MapTypes.Name.indexOf(g_GameList[g].mapType); Engine.GetGUIObjectByName("sgMapType").caption = mapTypeIdx != -1 ? g_MapTypes.Title[mapTypeIdx] : ""; - // Display map description if it exists, otherwise display a placeholder. - if (mapData && mapData.settings.Description) - var mapDescription = translate(mapData.settings.Description); - else - var mapDescription = translate("Sorry, no description available."); // Display map preview if it exists, otherwise display a placeholder. if (mapData && mapData.settings.Preview) var mapPreview = mapData.settings.Preview; else var mapPreview = "nopreview.png"; - Engine.GetGUIObjectByName("sgMapDescription").caption = mapDescription; - Engine.GetGUIObjectByName("sgMapPreview").sprite = "cropped:(0.7812,0.5859)session/icons/mappreview/" + mapPreview; + // Display map description and preview (or placeholder) + var mapData = getMapDescriptionAndPreview(g_GameList[g].mapType, g_GameList[g].mapName); + Engine.GetGUIObjectByName("sgMapDescription").caption = mapData.description; + Engine.GetGUIObjectByName("sgMapPreview").sprite = "cropped:(0.7812,0.5859)session/icons/mappreview/" + mapData.preview; } /** * Start the joining process on the currectly selected game. */ function joinSelectedGame() { var gamesBox = Engine.GetGUIObjectByName("gamesBox"); if (gamesBox.selected >= 0) { var g = gamesBox.list_data[gamesBox.selected]; var sname = g_Name; var sip = g_GameList[g].ip; // TODO: What about valid host names? // Check if it looks like an ip address if (sip.split('.').length != 4) { addChatMessage({ "from": "system", "text": sprintf(translate("This game's address '%(ip)s' does not appear to be valid."), { ip: sip }) }); return; } // Open Multiplayer connection window with join option. Engine.PushGuiPage("page_gamesetup_mp.xml", { multiplayerGameType: "join", name: sname, ip: sip, rating: g_userRating }); } } /** * Start the hosting process. */ function hostGame() { // Open Multiplayer connection window with host option. Engine.PushGuiPage("page_gamesetup_mp.xml", { multiplayerGameType: "host", name: g_Name, rating: g_userRating }); } //////////////////////////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////////////////////////// function stripColorCodes(input) { return input.replace(/\[(\w+)[^w]*?](.*?)\[\/\1]/g, '$2'); } //////////////////////////////////////////////////////////////////////////////////////////////// // GUI event handlers //////////////////////////////////////////////////////////////////////////////////////////////// function onTick() { updateTimers(); checkSpamMonitor(); // Receive messages while (true) { var message = Engine.LobbyGuiPollMessage(); // Clean Message if (!message) break; var text = escapeText(message.text); switch (message.type) { case "mucmessage": // For room messages var from = escapeText(message.from); addChatMessage({ "from": from, "text": text, "datetime": message.datetime}); break; case "message": // For private messages var from = escapeText(message.from); addChatMessage({ "from": from, "text": text, "datetime": message.datetime}); break; case "muc": var nick = message.text; switch(message.level) { case "join": addChatMessage({ "text": "/special " + sprintf(translate("%(nick)s has joined."), { nick: nick }), "key": g_specialKey }); break; case "leave": addChatMessage({ "text": "/special " + sprintf(translate("%(nick)s has left."), { nick: nick }), "key": g_specialKey }); break; case "nick": addChatMessage({ "text": "/special " + sprintf(translate("%(oldnick)s is now known as %(newnick)s."), { oldnick: nick, newnick: message.data }), "key": g_specialKey }); break; case "presence": break; case "subject": updateSubject(message.text); break; default: warn(sprintf("Unknown message.level '%(msglvl)s'", { msglvl: message.level })); break; } // We might receive many join/leaves when returning to the lobby from a long game. // To improve performance, only update the playerlist GUI when the last update in the current stack is processed if (Engine.LobbyGetMucMessageCount() == 0) updatePlayerList(); break; case "system": switch (message.level) { case "standard": addChatMessage({ "from": "system", "text": text, "color": "150 0 0" }); if (message.text == "disconnected") { // Clear the list of games and the list of players updateGameList(); updateLeaderboard(); updatePlayerList(); // Disable the 'host' button Engine.GetGUIObjectByName("hostButton").enabled = false; } else if (message.text == "connected") { Engine.GetGUIObjectByName("hostButton").enabled = true; } break; case "error": addChatMessage({ "from": "system", "text": text, "color": "150 0 0" }); break; case "internal": switch (message.text) { case "gamelist updated": updateGameList(); break; case "boardlist updated": updateLeaderboard(); break; case "ratinglist updated": updatePlayerList(); break; case "profile updated": updateProfile(); break; } break; } break; default: error(sprintf("Unrecognised message type %(msgtype)s", { msgtype: message.type })); } } } /* Messages */ function submitChatInput() { var input = Engine.GetGUIObjectByName("chatInput"); var text = input.caption; if (text.length) { if (!handleSpecialCommand(text) && !isSpam(text, g_Name)) Engine.LobbySendMessage(text); input.caption = ""; } } function isValidNick(nick) { var prohibitedNicks = ["system"]; return prohibitedNicks.indexOf(nick) == -1; } /** * Handle all '/' commands. * * @param text Text to be checked for commands. * @return true if more text processing is needed, false otherwise. */ function handleSpecialCommand(text) { if (text[0] != '/') return false; var [cmd, nick] = ircSplit(text); switch (cmd) { case "away": Engine.LobbySetPlayerPresence("away"); break; case "back": Engine.LobbySetPlayerPresence("available"); break; case "kick": // TODO: Split reason from nick and pass it too, for now just support "/kick nick" // also allow quoting nicks (and/or prevent users from changing it here, but that doesn't help if the spammer uses a different client) Engine.LobbyKick(nick, ""); break; case "ban": // TODO: Split reason from nick and pass it too, for now just support "/ban nick" Engine.LobbyBan(nick, ""); break; case "quit": returnToMainMenu(); break; case "say": case "me": return false; default: addChatMessage({ "from":"system", "text": sprintf(translate("We're sorry, the '%(cmd)s' command is not supported."), { cmd: cmd})}); } return true; } /** * Process and, if appropriate, display a formatted message. * * @param msg The message to be processed. */ function addChatMessage(msg) { // Some calls of this function will leave some msg parameters empty. Text is required though. if (msg.from) { // Display the moderator symbol in the chatbox. var playerRole = Engine.LobbyGetPlayerRole(msg.from); if (playerRole == "moderator") msg.from = g_modPrefix + msg.from; } else msg.from = null; if (!msg.color) msg.color = null; if (!msg.key) msg.key = null; if (!msg.datetime) msg.datetime = null; // Highlight local user's nick if (g_Name != msg.from) msg.text = msg.text.replace(g_Name, colorPlayerName(g_Name)); // Run spam test if it's not a historical message if (!msg.datetime) updateSpamMonitor(msg.from); if (isSpam(msg.text, msg.from)) return; // Format Text var formatted = ircFormat(msg.text, msg.from, msg.color, msg.key, msg.datetime); // If there is text, add it to the chat box. if (formatted) { g_ChatMessages.push(formatted); Engine.GetGUIObjectByName("chatText").caption = g_ChatMessages.join("\n"); } } function ircSplit(string) { var idx = string.indexOf(' '); if (idx != -1) return [string.substr(1,idx-1), string.substr(idx+1)]; return [string.substr(1), ""]; } /** * Format text in an IRC-like way. * * @param text Body of the message. * @param from Sender of the message. * @param color Optional color of sender. * @param key Key to verify join/leave messages with. TODO: Remove this, it only provides synthetic security. * @param datetime Current date and time of message, only used for historical messages * @return Formatted text. */ function ircFormat(text, from, color, key, datetime) { // Generate and apply color to uncolored names, if (!color && from) var coloredFrom = colorPlayerName(from); else if (color && from) var coloredFrom = '[color="' + color + '"]' + from + "[/color]"; // Handle commands allowed past handleSpecialCommand. if (text[0] == '/') { var [command, message] = ircSplit(text); switch (command) { case "me": // Translation: IRC message prefix when the sender uses the /me command. var senderString = '[font="sans-bold-13"]' + sprintf(translate("* %(sender)s"), { sender: coloredFrom }) + '[/font]'; // Translation: IRC message issued using the ‘/me’ command. var formattedMessage = sprintf(translate("%(sender)s %(action)s"), { sender: senderString, action: message }); break; case "say": // Translation: IRC message prefix. var senderString = '[font="sans-bold-13"]' + sprintf(translate("<%(sender)s>"), { sender: coloredFrom }) + '[/font]'; // Translation: IRC message. var formattedMessage = sprintf(translate("%(sender)s %(message)s"), { sender: senderString, message: message }); break case "special": if (key === g_specialKey) // Translation: IRC system message. var formattedMessage = '[font="sans-bold-13"]' + sprintf(translate("== %(message)s"), { message: message }) + '[/font]'; else { // Translation: IRC message prefix. var senderString = '[font="sans-bold-13"]' + sprintf(translate("<%(sender)s>"), { sender: coloredFrom }) + '[/font]'; // Translation: IRC message. var formattedMessage = sprintf(translate("%(sender)s %(message)s"), { sender: senderString, message: message }); } break; default: // This should never happen. var formattedMessage = ""; } } else { // Translation: IRC message prefix. var senderString = '[font="sans-bold-13"]' + sprintf(translate("<%(sender)s>"), { sender: coloredFrom }) + '[/font]'; // Translation: IRC message. var formattedMessage = sprintf(translate("%(sender)s %(message)s"), { sender: senderString, message: text }); } // Build time header if enabled if (g_timestamp) { var time; if (datetime) { var parserDate = datetime.split("T")[0].split("-"); var parserTime = datetime.split("T")[1].split(":"); // See http://xmpp.org/extensions/xep-0082.html#sect-idp285136 for format of datetime // Date takes Year, Month, Day, Hour, Minute, Second time = new Date(Date.UTC(parserDate[0], parserDate[1], parserDate[2], parserTime[0], parserTime[1], parserTime[2].split("Z")[0])); } else time = new Date(Date.now()); // 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 var timeString = Engine.FormatMillisecondsIntoDateString(time.getTime(), translate("HH:mm")); // Translation: Time prefix as shown in the multiplayer lobby (when you enable it in the options page). var timePrefixString = '[font="sans-bold-13"]' + sprintf(translate("\\[%(time)s]"), { time: timeString }) + '[/font]'; // Translation: IRC message format when there is a time prefix. return sprintf(translate("%(time)s %(message)s"), { time: timePrefixString, message: formattedMessage }); } else return formattedMessage; } /** * Update the spam monitor. * * @param from User to update. */ function updateSpamMonitor(from) { // Integer time in seconds. var time = Math.floor(Date.now() / 1000); // Update or initialize the user in the spam monitor. if (g_spamMonitor[from]) g_spamMonitor[from][0]++; else g_spamMonitor[from] = [1, time, 0]; } /** * Check if a message is spam. * * @param text Body of message. * @param from Sender of message. * @return True if message should be blocked. */ function isSpam(text, from) { // Integer time in seconds. var time = Math.floor(Date.now() / 1000); // Initialize if not already in the database. if (!g_spamMonitor[from]) g_spamMonitor[from] = [1, time, 0]; // Block blank lines. if (text == " ") return true; // Block users who are still within their spam block period. else if (g_spamMonitor[from][2] + SPAM_BLOCK_LENGTH >= time) return true; // Block users who exceed the rate of 1 message per second for five seconds and are not already blocked. TODO: Make this smarter and block profanity. else if (g_spamMonitor[from][0] == 6) { g_spamMonitor[from][2] = time; if (from == g_Name) addChatMessage({ "from": "system", "text": translate("Please do not spam. You have been blocked for thirty seconds.") }); return true; } // Return false if everything is clear. else return false; } /** * Reset timer used to measure message send speed. */ function checkSpamMonitor() { // Integer time in seconds. var time = Math.floor(Date.now() / 1000); // Clear message count every 5 seconds. for each (var stats in g_spamMonitor) { if (stats[1] + 5 <= time) { stats[1] = time; stats[0] = 0; } } } /* Utilities */ // Generate a (mostly) unique color for this player based on their name. // See http://stackoverflow.com/questions/3426404/create-a-hexadecimal-colour-based-on-a-string-with-jquery-javascript function getPlayerColor(playername) { // Generate a probably-unique hash for the player name and use that to create a color. var hash = 0; for (var i = 0; i < playername.length; i++) 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. var [h, s, l] = rgbToHsl(hash >> 24 & 0xFF, hash >> 16 & 0xFF, hash >> 8 & 0xFF); return hslToRgb(h, s, Math.max(0.4, l)).join(" "); } function repeatString(times, string) { return Array(times + 1).join(string); } // Some names are special and should always appear in certain colors. var fixedColors = { "system": repeatString(7, "255.0.0."), "@WFGbot": repeatString(7, "255.24.24."), "pyrogenesis": repeatString(2, "97.0.0.") + repeatString(2, "124.0.0.") + "138.0.0." + repeatString(2, "174.0.0.") + repeatString(2, "229.40.0.") + repeatString(2, "243.125.15.") }; function colorPlayerName(playername) { var color = fixedColors[playername]; if (color) { color = color.split("."); return ('[color="' + playername.split("").map(function (c, i) color.slice(i * 3, i * 3 + 3).join(" ") + '"]' + c + '[/color][color="') .join("") + '"]').slice(0, -10); } return '[color="' + getPlayerColor(playername.replace(g_modPrefix, "")) + '"]' + playername + '[/color]'; } // Ensure `value` is between 0 and 1. function clampColorValue(value) { return Math.abs(1 - Math.abs(value - 1)); } // See http://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion function rgbToHsl(r, g, b) { r /= 255; g /= 255; b /= 255; var max = Math.max(r, g, b), min = Math.min(r, g, b); var h, s, l = (max + min) / 2; if (max == min) h = s = 0; // achromatic else { var d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } return [h, s, l]; } function hslToRgb(h, s, l) { function hue2rgb(p, q, t) { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1/6) return p + (q - p) * 6 * t; if (t < 1/2) return q; if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; return p; } [h, s, l] = [h, s, l].map(clampColorValue); var r, g, b; if (s == 0) r = g = b = l; // achromatic else { var q = l < 0.5 ? l * (1 + s) : l + s - l * s; var p = 2 * l - q; r = hue2rgb(p, q, h + 1/3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1/3); } return [r, g, b].map(function (n) Math.round(n * 255)); } (function () { function hexToRgb(hex) { return parseInt(hex.slice(0, 2), 16) + "." + parseInt(hex.slice(2, 4), 16) + "." + parseInt(hex.slice(4, 6), 16) + "."; } function r(times, hex) { return repeatString(times, hexToRgb(hex)); } fixedColors["Twilight_Sparkle"] = r(2, "d19fe3") + r(2, "b689c8") + r(2, "a76bc2") + r(4, "263773") + r(2, "131f46") + r(2, "662d8a") + r(2, "ed438a"); fixedColors["Applejack"] = r(3, "ffc261") + r(3, "efb05d") + r(3, "f26f31"); fixedColors["Rarity"] = r(1, "ebeff1") + r(1, "dee3e4") + r(1, "bec2c3") + r(1, "83509f") + r(1, "4b2568") + r(1, "4917d6"); fixedColors["Rainbow_Dash"] = r(2, "ee4144") + r(1, "f37033") + r(1, "fdf6af") + r(1, "62bc4d") + r(1, "1e98d3") + r(2, "672f89") + r(1, "9edbf9") + r(1, "88c4eb") + r(1, "77b0e0") + r(1, "1e98d3"); fixedColors["Pinkie_Pie"] = r(2, "f3b6cf") + r(2, "ec9dc4") + r(4, "eb81b4") + r(1, "ed458b") + r(1, "be1d77"); fixedColors["Fluttershy"] = r(2, "fdf6af") + r(2, "fee78f") + r(2, "ead463") + r(2, "f3b6cf") + r(2, "eb81b4"); fixedColors["Sweetie_Belle"] = r(2, "efedee") + r(3, "e2dee3") + r(3, "cfc8d1") + r(2, "b28dc0") + r(2, "f6b8d2") + r(1, "795b8a"); fixedColors["Apple_Bloom"] = r(2, "f4f49b") + r(2, "e7e793") + r(2, "dac582") + r(2, "f46091") + r(2, "f8415f") + r(1, "c52451"); fixedColors["Scootaloo"] = r(2, "fbba64") + r(2, "f2ab56") + r(2, "f37003") + r(2, "bf5d95") + r(1, "bf1f79"); fixedColors["Luna"] = r(1, "7ca7fa") + r(1, "5d6fc1") + r(1, "656cb9") + r(1, "393993"); fixedColors["Celestia"] = r(1, "fdfafc") + r(1, "f7eaf2") + r(1, "d99ec5") + r(1, "00aec5") + r(1, "f7c6dc") + r(1, "98d9ef") + r(1, "ced7ed") + r(1, "fed17b"); })();