Index: ps/trunk/binaries/data/config/default.cfg =================================================================== --- ps/trunk/binaries/data/config/default.cfg (revision 19605) +++ ps/trunk/binaries/data/config/default.cfg (revision 19606) @@ -1,441 +1,444 @@ ; Global Configuration Settings ; ; ************************************************************** ; * DO NOT EDIT THIS FILE if you want personal customisations: * ; * create a text file called "local.cfg" instead, and copy * ; * the lines from this file that you want to change. * ; * * ; * If a setting is part of a section (for instance [hotkey]) * ; * you need to append the section name at the beginning of * ; * your custom line (for instance you need to write * ; * "hotkey.pause = Space" if you want to change the pausing * ; * hotkey to the spacebar). * ; * * ; * On Linux, create: * ; * $XDG_CONFIG_HOME/0ad/config/local.cfg * ; * (Note: $XDG_CONFIG_HOME defaults to ~/.config) * ; * * ; * On OS X, create: * ; * ~/Library/Application\ Support/0ad/config/local.cfg * ; * * ; * On Windows, create: * ; * %appdata%\0ad\config\local.cfg * ; * * ; ************************************************************** ; Enable/disable windowed mode by default. (Use Alt+Enter to toggle in the game.) windowed = false ; Show detailed tooltips (Unit stats) showdetailedtooltips = false ; Pause the game on window focus loss (Only applicable to single player mode) pauseonfocusloss = true ; Persist settings after leaving the game setup screen persistmatchsettings = true ; Default player name to use in multiplayer ; playername = "anonymous" ; Default server name or IP to use in multiplayer multiplayerserver = "127.0.0.1" ; Force a particular resolution. (If these are 0, the default is ; to keep the current desktop resolution in fullscreen mode or to ; use 1024x768 in windowed mode.) xres = 0 yres = 0 ; Force a non-standard bit depth (if 0 then use the current desktop bit depth) bpp = 0 ; Preferred display (for multidisplay setups, only works with SDL 2.0) display = 0 ; Emulate right-click with Ctrl+Click on Mac mice macmouse = false ; System settings: ; if false, actors won't be rendered but anything entity will be. renderactors = true waterugly=false; Force usage of the fixed pipeline water. This is faster, but really, really ugly. waterfancyeffects = false waterrealdepth = true waterrefraction = true waterreflection = true shadowsonwater = false shadows = true shadowpcf = true vsync = false particles = true silhouettes = true showsky = true nos3tc = false noautomipmap = true novbo = false noframebufferobject = false ; Disable hardware cursors nohwcursor = false ; Linux only: Set the driconf force_s3tc_enable option at startup, ; for compressed texture support force_s3tc_enable = true ; Specify the render path. This can be one of: ; default Automatically select one of the below, depending on system capabilities ; fixed Only use OpenGL fixed function pipeline ; shader Use vertex/fragment shaders for transform and lighting where possible ; Using 'fixed' instead of 'default' may work around some graphics-related problems, ; but will reduce performance and features when a modern graphics card is available. renderpath = default ;;;;; EXPERIMENTAL ;;;;; ; Prefer GLSL shaders over ARB shaders. Allows fancier graphical effects. preferglsl = false ; Experimental probably-non-working GPU skinning support; requires preferglsl; use at own risk gpuskinning = false ; Use smooth LOS interpolation smoothlos = false ; Use screen-space postprocessing filters (HDR, bloom, DOF, etc). Incompatible with fixed renderpath. postproc = false ; Quality level of shader effects (set to 10 to display all effects) materialmgr.quality = 2.0 ; Maximum distance to display parallax effect. Set to 0 to disable parallax. materialmgr.PARALLAX_DIST.max = 150 ; Maximum distance to display high quality parallax effect. materialmgr.PARALLAX_HQ_DIST.max = 75 ; Maximum distance to display very high quality parallax effect. Set to 30 to enable. materialmgr.PARALLAX_VHQ_DIST.max = 0 ;;;;;;;;;;;;;;;;;;;;;;;; ; Replace alpha-blending with alpha-testing, for performance experiments forcealphatest = false ; Color of the sky (in "r g b" format) skycolor = "0 0 0" [adaptivefps] session = 60 ; Throttle FPS in running games (prevents 100% CPU workload). menu = 30 ; Throttle FPS in menus only. [hotkey] ; Each one of the specified keys will trigger the action on the left ; for multiple-key combinations, separate keys with '+'. ; See keys.txt for the list of key names. ; > SYSTEM SETTINGS exit = "Ctrl+Break", "Super+Q" ; Exit to desktop cancel = Escape ; Close or cancel the current dialog box/popup leave = Escape ; End current game or Exit confirm = Return ; Confirm the current command pause = Pause ; Pause/unpause game screenshot = F2 ; Take PNG screenshot bigscreenshot = "Shift+F2" ; Take large BMP screenshot togglefullscreen = "Alt+Return" ; Toggle fullscreen/windowed mode screenshot.watermark = "Alt+K" ; Toggle product/company watermark for official screenshots wireframe = "Alt+W" ; Toggle wireframe mode silhouettes = "Alt+S" ; Toggle unit silhouettes showsky = "Alt+Z" ; Toggle sky ; > CLIPBOARD CONTROLS copy = "Ctrl+C" ; Copy to clipboard paste = "Ctrl+V" ; Paste from clipboard cut = "Ctrl+X" ; Cut selected text and copy to the clipboard ; > CONSOLE SETTINGS console.toggle = BackQuote, F9 ; Open/close console ; > OVERLAY KEYS fps.toggle = "Alt+F" ; Toggle frame counter realtime.toggle = "Alt+T" ; Toggle current display of computer time session.devcommands.toggle = "Alt+D" ; Toggle developer commands panel timeelapsedcounter.toggle = "F12" ; Toggle time elapsed counter session.showstatusbars = Tab ; Toggle display of status bars session.highlightguarding = PgDn ; Toggle highlight of guarding units session.highlightguarded = PgUp ; Toggle highlight of guarded units session.toggleaurarange = "Alt+V" ; Toggle rendering of aura range overlays of selected units and structures ; > HOTKEYS ONLY chat = Return ; Toggle chat window teamchat = "T" ; Toggle chat window in team chat mode privatechat = "L" ; Toggle chat window and select the previous private chat partner ; > QUICKSAVE quicksave = "Shift+F5" quickload = "Shift+F8" [hotkey.camera] reset = "R" ; Reset camera rotation to default. follow = "F" ; Follow the first unit in the selection rallypointfocus = unused ; Focus the camera on the rally point of the selected building zoom.in = Plus, Equals, NumPlus ; Zoom camera in (continuous control) zoom.out = Minus, NumMinus ; Zoom camera out (continuous control) zoom.wheel.in = WheelUp ; Zoom camera in (stepped control) zoom.wheel.out = WheelDown ; Zoom camera out (stepped control) rotate.up = "Ctrl+UpArrow", "Ctrl+W" ; Rotate camera to look upwards rotate.down = "Ctrl+DownArrow", "Ctrl+S" ; Rotate camera to look downwards rotate.cw = "Ctrl+LeftArrow", "Ctrl+A", Q ; Rotate camera clockwise around terrain rotate.ccw = "Ctrl+RightArrow", "Ctrl+D", E ; Rotate camera anticlockwise around terrain rotate.wheel.cw = "Shift+WheelUp", MouseX1 ; Rotate camera clockwise around terrain (stepped control) rotate.wheel.ccw = "Shift+WheelDown", MouseX2 ; Rotate camera anticlockwise around terrain (stepped control) pan = MouseMiddle ; Enable scrolling by moving mouse left = A, LeftArrow ; Scroll or rotate left right = D, RightArrow ; Scroll or rotate right up = W, UpArrow ; Scroll or rotate up/forwards down = S, DownArrow ; Scroll or rotate down/backwards scroll.speed.increase = "Ctrl+Shift+S" ; Increase scroll speed scroll.speed.decrease = "Ctrl+Alt+S" ; Decrease scroll speed rotate.speed.increase = "Ctrl+Shift+R" ; Increase rotation speed rotate.speed.decrease = "Ctrl+Alt+R" ; Decrease rotation speed zoom.speed.increase = "Ctrl+Shift+Z" ; Increase zoom speed zoom.speed.decrease = "Ctrl+Alt+Z" ; Decrease zoom speed [hotkey.camera.jump] 1 = F5 ; Jump to position N 2 = F6 3 = F7 4 = F8 ;5 = ;6 = ;7 = ;8 = ;9 = ;10 = [hotkey.camera.jump.set] 1 = "Ctrl+F5" ; Set jump position N 2 = "Ctrl+F6" 3 = "Ctrl+F7" 4 = "Ctrl+F8" ;5 = ;6 = ;7 = ;8 = ;9 = ;10 = [hotkey.profile] toggle = "F11" ; Enable/disable real-time profiler save = "Shift+F11" ; Save current profiler data to logs/profile.txt [hotkey.profile2] toggle = "Ctrl+F11" ; Enable/disable HTTP/GPU modes for new profiler [hotkey.selection] add = Shift ; Add units to selection milonly = Alt ; Add only military units to selection idleonly = "I" ; Select only idle units remove = Ctrl ; Remove units from selection cancel = Esc ; Un-select all units and cancel building placement idleworker = Period ; Select next idle worker idlewarrior = ForwardSlash ; Select next idle warrior offscreen = Alt ; Include offscreen units in selection [hotkey.selection.group.add] 0 = "Shift+0" 1 = "Shift+1" 2 = "Shift+2" 3 = "Shift+3" 4 = "Shift+4" 5 = "Shift+5" 6 = "Shift+6" 7 = "Shift+7" 8 = "Shift+8" 9 = "Shift+9" [hotkey.selection.group.save] 0 = "Ctrl+0" 1 = "Ctrl+1" 2 = "Ctrl+2" 3 = "Ctrl+3" 4 = "Ctrl+4" 5 = "Ctrl+5" 6 = "Ctrl+6" 7 = "Ctrl+7" 8 = "Ctrl+8" 9 = "Ctrl+9" [hotkey.selection.group.select] 0 = 0 1 = 1 2 = 2 3 = 3 4 = 4 5 = 5 6 = 6 7 = 7 8 = 8 9 = 9 [hotkey.session] kill = Delete ; Destroy selected units stop = "H" ; Stop the current action backtowork = "Y" ; The unit will go back to work unload = "U" ; Unload garrisoned units when a building/mechanical unit is selected attack = Ctrl ; Modifier to attack instead of another action (eg capture) attackmove = Ctrl ; Modifier to attackmove when clicking on a point attackmoveUnit = "Ctrl+Q" ; Modifier to attackmove targeting only units when clicking on a point (should contain the attackmove keys) garrison = Ctrl ; Modifier to garrison when clicking on building autorallypoint = Ctrl ; Modifier to set the rally point on the building itself guard = "G" ; Modifier to escort/guard when clicking on unit/building patrol = "P" ; Modifier to patrol a unit repair = "J" ; Modifier to repair when clicking on building/mechanical unit queue = Shift ; Modifier to queue unit orders instead of replacing batchtrain = Shift ; Modifier to train units in batches massbarter = Shift ; Modifier to barter bunch of resources masstribute = Shift ; Modifier to tribute bunch of resources noconfirmation = Shift ; Do not ask confirmation when deleting a building/unit fulltradeswap = Shift ; Modifier to put the desired trade resource to 100% unloadtype = Shift ; Modifier to unload all units of type deselectgroup = Ctrl ; Modifier to deselect units when clicking group icon, instead of selecting rotate.cw = RightBracket ; Rotate building placement preview clockwise rotate.ccw = LeftBracket ; Rotate building placement preview anticlockwise [hotkey.session.gui] toggle = "Alt+G" ; Toggle visibility of session GUI menu.toggle = "F10" ; Toggle in-game menu barter.toggle = "Ctrl+B" ; Toggle in-game barter/trade page [hotkey.session.savedgames] delete = Delete ; Delete the selected saved game asking confirmation noconfirmation = Shift ; Do not ask confirmation when deleting a game [hotkey.session.queueunit] ; > UNIT TRAINING 1 = "Z" ; add first unit type to queue 2 = "X" ; add second unit type to queue 3 = "C" ; add third unit type to queue 4 = "V" ; add fourth unit type to queue 5 = "B" ; add fivth unit type to queue 6 = "N" ; add sixth unit type to queue 7 = "M" ; add seventh unit type to queue 8 = Comma ; add eighth unit type to queue [hotkey.session.timewarp] fastforward = Space ; If timewarp mode enabled, speed up the game rewind = Backspace ; If timewarp mode enabled, go back to earlier point in the game [hotkey.text] ; > GUI TEXTBOX HOTKEYS delete.left = "Ctrl+Backspace" ; Delete word to the left of cursor delete.right = "Ctrl+Del" ; Delete word to the right of cursor move.left = "Ctrl+LeftArrow" ; Move cursor to start of word to the left of cursor move.right = "Ctrl+RightArrow" ; Move cursor to start of word to the right of cursor [gui] cursorblinkrate = 0.5 ; Cursor blink rate in seconds (0.0 to disable blinking) scale = 1.0 ; GUI scaling factor, for improved compatibility with 4K displays [gui.gamesetup] enabletips = true ; Enable/Disable tips during gamesetup (for newcomers) [gui.session] camerajump.threshold = 40 ; How close do we have to be to the actual location in order to jump back to the previous one? timeelapsedcounter = false ; Show the game duration in the top right corner batchtrainingsize = 5 ; Number of units to be trained per batch (when pressing the hotkey) aurarange = true [gui.session.minimap] blinkduration = 1.7 ; The blink duration while pinging pingduration = 50.0 ; The duration for which an entity will be pinged after an attack notification [gui.session.notifications] attack = true ; Show a chat notification if you are attacked by another player tribute = true ; Show a chat notification if an ally tributes resources to another team member if teams are locked, and all tributes in observer mode barter = true ; Show a chat notification to observers when a player bartered resources phase = completed ; Show a chat notification if you or an ally have started, aborted or completed a new phase, and phases of all players in observer mode. Possible values: none, completed, all. [gui.splashscreen] enable = true ; Enable/disable the splashscreen version = 0 ; Splashscreen version (date of last modification). By default, 0 to force splashscreen to appear at first launch [joystick] ; EXPERIMENTAL: joystick/gamepad settings enable = false deadzone = 8192 [joystick.camera] pan.x = 0 pan.y = 1 rotate.x = 3 rotate.y = 2 zoom.in = 5 zoom.out = 4 [chat] timestamp = true ; Show at which time chat messages have been sent [chat.session] extended = true ; Whether to display the chat history [lobby] history = 0 ; Number of past messages to display on join room = "arena22" ; Default MUC room to join server = "lobby.wildfiregames.com" ; Address of lobby server xpartamupp = "wfgbot22" ; Name of the server-side xmpp client that manage games buddies = "," ; Comma separated list of playernames that the current user has marked as buddies +[lobby.columns] +gamerating = false ; Show the average rating of the participating players in a column of the gamelist + [mod] enabledmods = "mod public" [network] duplicateplayernames = false ; Rename joining player to "User (2)" if "User" is already connected, otherwise prohibit join. lateobserverjoins = true ; Allow observers to join the game after it started observerlimit = 8 ; Prevent further observer joins in running games if this limit is reached [overlay] fps = "false" ; Show frames per second in top right corner realtime = "false" ; Show current system time in top right corner netwarnings = "true" ; Show warnings if the network connection is bad [profiler2] autoenable = false ; Enable HTTP server output at startup (default off for security/performance) gpu.arb.enable = true ; Allow GL_ARB_timer_query timing mode when available gpu.ext.enable = true ; Allow GL_EXT_timer_query timing mode when available gpu.intel.enable = true ; Allow GL_INTEL_performance_queries timing mode when available [sound] mastergain = 0.9 musicgain = 0.2 ambientgain = 0.6 actiongain = 0.7 uigain = 0.7 [sound.notify] nick = true ; Play a sound when someone mentions your name in the lobby or game [tinygettext] debug = false ; Print error messages each time a translation for an English string is not found. [userreport] ; Opt-in online user reporting system url = "http://feedback.wildfiregames.com/report/upload/v1/" [view] ; Camera control settings scroll.speed = 120.0 scroll.speed.modifier = 1.05 ; Multiplier for changing scroll speed rotate.x.speed = 1.2 rotate.x.min = 28.0 rotate.x.max = 60.0 rotate.x.default = 35.0 rotate.y.speed = 2.0 rotate.y.speed.wheel = 0.45 rotate.y.default = 0.0 rotate.speed.modifier = 1.05 ; Multiplier for changing rotation speed drag.speed = 0.5 zoom.speed = 256.0 zoom.speed.wheel = 32.0 zoom.min = 50.0 zoom.max = 200.0 zoom.default = 120.0 zoom.speed.modifier = 1.05 ; Multiplier for changing zoom speed pos.smoothness = 0.1 zoom.smoothness = 0.4 rotate.x.smoothness = 0.5 rotate.y.smoothness = 0.3 near = 2.0 ; Near plane distance far = 4096.0 ; Far plane distance fov = 45.0 ; Field of view (degrees), lower is narrow, higher is wide height.smoothness = 0.5 height.min = 16 Index: ps/trunk/binaries/data/mods/public/gui/common/functions_utility.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/common/functions_utility.js (revision 19605) +++ ps/trunk/binaries/data/mods/public/gui/common/functions_utility.js (revision 19606) @@ -1,269 +1,260 @@ /** * Used by notifyUser() to limit the number of pings */ var g_LastNickNotification = -1; // 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; } function getJSONFileList(pathname) { // Remove the path and extension from each name, since we just want the filename return Engine.BuildDirEntList(pathname, "*.json", false).map( filename => filename.substring(pathname.length, filename.length-5)); } // 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, limitLength = true) { if (!text) return text; if (limitLength) text = text.substr(0, 255); return text.replace(/\\/g, "\\\\").replace(/\[/g, "\\["); } function unescapeText(text) { if (!text) return text; return text.replace(/\\\\/g, "\\").replace(/\\\[/g, "\["); } /** * Merge players by team to remove duplicate Team entries, thus reducing the packet size of the lobby report. */ function playerDataToStringifiedTeamList(playerData) { let teamList = {}; for (let pData of playerData) { let team = pData.Team === undefined ? -1 : pData.Team; if (!teamList[team]) teamList[team] = []; teamList[team].push(pData); delete teamList[team].Team; } return escapeText(JSON.stringify(teamList), false); } function stringifiedTeamListToPlayerData(stringifiedTeamList) { let teamList = JSON.parse(unescapeText(stringifiedTeamList)); let playerData = []; for (let team in teamList) for (let pData of teamList[team]) { pData.Team = team; playerData.push(pData); } return playerData; } function translateMapTitle(mapTitle) { return mapTitle == "random" ? translateWithContext("map selection", "Random") : translate(mapTitle); } /** * 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) { return Engine.FormatMillisecondsIntoDateStringGMT(time, time < 1000 * 60 * 60 ? translate("mm:ss") : translate("HH:mm:ss")); } 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); } } // 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 singleplayerName() { return Engine.ConfigDB_GetValue("user", "playername.singleplayer") || Engine.GetSystemUsername(); } function multiplayerName() { return Engine.ConfigDB_GetValue("user", "playername.multiplayer") || Engine.GetSystemUsername(); } -/** - * Returns the nickname without the lobby rating. - */ -function removeRatingFromNick(playerName) -{ - let result = /^(\S+)\ \(\d+\)$/g.exec(playerName); - return result ? result[1] : playerName; -} - 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(guiObject, playernames) { let text = guiObject.caption; if (!text.length) return; let bufferPosition = guiObject.buffer_position; let textTillBufferPosition = text.substring(0, bufferPosition); let newText = tryAutoComplete(textTillBufferPosition, playernames); guiObject.caption = newText + text.substring(bufferPosition); guiObject.buffer_position = bufferPosition + (newText.length - textTillBufferPosition.length); } function clearChatMessages() { g_ChatMessages.length = 0; Engine.GetGUIObjectByName("chatText").caption = ""; try { for (let timer of g_ChatTimers) clearTimeout(timer); g_ChatTimers.length = 0; } catch (e) { } } /** * Plays a sound if user's nick is mentioned in chat */ function notifyUser(userName, msgText) { if (Engine.ConfigDB_GetValue("user", "sound.notify.nick") != "true" || msgText.toLowerCase().indexOf(userName.toLowerCase()) == -1) return; let timeNow = new Date().getTime(); if (!g_LastNickNotification || timeNow > g_LastNickNotification + 3000) Engine.PlayUISound("audio/interface/ui/chat_alert.ogg", false); g_LastNickNotification = timeNow; } /** * Horizontally spaces objects within a parent * * @param margin The gap, in px, between the objects */ function horizontallySpaceObjects(parentName, margin=0) { let objects = Engine.GetGUIObjectByName(parentName).children; for (let i = 0; i < objects.length; ++i) { let size = objects[i].size; let width = size.right - size.left; size.left = i * (width + margin) + margin; size.right = (i + 1) * (width + margin); objects[i].size = size; } } /** * Hide all children after a certain index */ function hideRemaining(parentName, start = 0) { let objects = Engine.GetGUIObjectByName(parentName).children; for (let i = start; i < objects.length; ++i) objects[i].hidden = true; } Index: ps/trunk/binaries/data/mods/public/gui/common/gamedescription.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/common/gamedescription.js (revision 19605) +++ ps/trunk/binaries/data/mods/public/gui/common/gamedescription.js (revision 19606) @@ -1,390 +1,409 @@ /** * Highlights the victory condition in the game-description. */ var g_DescriptionHighlight = "orange"; /** + * The rating assigned to lobby players who didn't complete a ranked 1v1 yet. + */ +var g_DefaultLobbyRating = 1200; + +/** * XEP-0172 doesn't restrict nicknames, but our lobby policy does. * So use this human readable delimiter to separate buddy names in the config file. */ var g_BuddyListDelimiter = ","; + +/** + * Returns the nickname without the lobby rating. + */ +function splitRatingFromNick(playerName) +{ + let result = /^(\S+)\ \((\d+)\)$/g.exec(playerName); + + if (!result) + return [playerName, g_DefaultLobbyRating]; + + return [result[1], +result[2]]; +} + /** * Array of playernames that the current user has marked as buddies. */ var g_Buddies = Engine.ConfigDB_GetValue("user", "lobby.buddies").split(g_BuddyListDelimiter); /** * Denotes which players are a lobby buddy of the current user. */ var g_BuddySymbol = '•'; /** * Returns map description and preview image or placeholder. */ function getMapDescriptionAndPreview(mapType, mapName) { let 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"); 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" }; } /** * Sets the mappreview image correctly. * It needs to be cropped as the engine only allows loading square textures. * * @param {string} guiObject * @param {string} filename */ function setMapPreviewImage(guiObject, filename) { Engine.GetGUIObjectByName(guiObject).sprite = "cropped:" + 400/512 + "," + 300/512 + ":" + "session/icons/mappreview/" + filename; } /** * Returns a formatted string describing the player assignments. * Needs g_CivData to translate! * * @param {object} playerDataArray - As known from gamesetup and simstate. * @param {(string[]|false)} playerStates - One of "won", "defeated", "active" for each player. * @returns {string} */ function formatPlayerInfo(playerDataArray, playerStates) { let playerDescriptions = {}; let playerIdx = 0; for (let playerData of playerDataArray) { if (playerData == null || playerData.Civ && playerData.Civ == "gaia") continue; ++playerIdx; let teamIdx = playerData.Team; let isAI = playerData.AI && playerData.AI != ""; let playerState = playerStates && playerStates[playerIdx] || playerData.State; let isActive = !playerState || playerState == "active"; let playerDescription; if (isAI) { if (playerData.Civ) { if (isActive) // Translation: Describe a player in a selected game, f.e. in the replay- or savegame menu playerDescription = translate("%(playerName)s (%(civ)s, %(AIdifficulty)s %(AIname)s)"); else // Translation: Describe a player in a selected game, f.e. in the replay- or savegame menu playerDescription = translate("%(playerName)s (%(civ)s, %(AIdifficulty)s %(AIname)s, %(state)s)"); } else { if (isActive) // Translation: Describe a player in a selected game, f.e. in the replay- or savegame menu playerDescription = translate("%(playerName)s (%(AIdifficulty)s %(AIname)s)"); else // Translation: Describe a player in a selected game, f.e. in the replay- or savegame menu playerDescription = translate("%(playerName)s (%(AIdifficulty)s %(AIname)s, %(state)s)"); } } else { if (playerData.Offline) { // Can only occur in the lobby for now, so no strings with civ needed if (isActive) // Translation: Describe a player in a selected game, f.e. in the replay- or savegame menu playerDescription = translate("%(playerName)s (OFFLINE)"); else // Translation: Describe a player in a selected game, f.e. in the replay- or savegame menu playerDescription = translate("%(playerName)s (OFFLINE, %(state)s)"); } else { if (playerData.Civ) if (isActive) // Translation: Describe a player in a selected game, f.e. in the replay- or savegame menu playerDescription = translate("%(playerName)s (%(civ)s)"); else // Translation: Describe a player in a selected game, f.e. in the replay- or savegame menu playerDescription = translate("%(playerName)s (%(civ)s, %(state)s)"); else if (isActive) // Translation: Describe a player in a selected game, f.e. in the replay- or savegame menu playerDescription = translate("%(playerName)s"); else // Translation: Describe a player in a selected game, f.e. in the replay- or savegame menu playerDescription = translate("%(playerName)s (%(state)s)"); } } // Sort player descriptions by team if (!playerDescriptions[teamIdx]) playerDescriptions[teamIdx] = []; playerDescriptions[teamIdx].push(sprintf(playerDescription, { "playerName": '[color="' + (typeof getPlayerColor == 'function' ? (isAI ? "white" : getPlayerColor(playerData.Name)) : rgbToGuiColor(playerData.Color || g_Settings.PlayerDefaults[playerIdx].Color)) + '"]' + - (g_Buddies.indexOf(removeRatingFromNick(playerData.Name)) != -1 ? g_BuddySymbol + " " : "") + + (g_Buddies.indexOf(splitRatingFromNick(playerData.Name)[0]) != -1 ? g_BuddySymbol + " " : "") + escapeText(playerData.Name) + "[/color]", "civ": !playerData.Civ ? translate("Unknown Civilization") : g_CivData && g_CivData[playerData.Civ] && g_CivData[playerData.Civ].Name ? translate(g_CivData[playerData.Civ].Name) : playerData.Civ, "state": playerState == "defeated" ? translateWithContext("playerstate", "defeated") : translateWithContext("playerstate", "won"), "AIname": isAI ? translateAIName(playerData.AI) : "", "AIdifficulty": isAI ? translateAIDifficulty(playerData.AIDiff) : "" })); } let teams = Object.keys(playerDescriptions); if (teams.indexOf("observer") > -1) teams.splice(teams.indexOf("observer"), 1); let teamDescription = []; // If there are no teams, merge all playersDescriptions if (teams.length == 1) teamDescription.push(playerDescriptions[teams[0]].join("\n")); // If there are teams, merge "Team N:" + playerDescriptions else teamDescription = teams.map(team => { let teamCaption = team == -1 ? translate("No Team") : sprintf(translate("Team %(team)s"), { "team": +team + 1 }); // Translation: Describe players of one team in a selected game, f.e. in the replay- or savegame menu or lobby return sprintf(translate("%(team)s:\n%(playerDescriptions)s"), { "team": '[font="sans-bold-14"]' + teamCaption + "[/font]", "playerDescriptions": playerDescriptions[team].join("\n") }); }); if (playerDescriptions.observer) teamDescription.push(sprintf(translate("%(team)s:\n%(playerDescriptions)s"), { "team": '[font="sans-bold-14"]' + translatePlural("Observer", "Observers", playerDescriptions.observer.length) + "[/font]", "playerDescriptions": playerDescriptions.observer.join("\n") })); return teamDescription.join("\n\n"); } /** * Sets an additional map label, map preview image and describes the chosen gamesettings more closely. * * Requires g_GameAttributes and g_VictoryConditions. */ function getGameDescription(extended = false) { let titles = []; let victoryIdx = g_VictoryConditions.Name.indexOf(g_GameAttributes.settings.GameType || g_VictoryConditions.Default); if (victoryIdx != -1) { let title = g_VictoryConditions.Title[victoryIdx]; if (g_VictoryConditions.Name[victoryIdx] == "wonder") title = sprintf( translatePluralWithContext( "victory condition", "Wonder (%(min)s minute)", "Wonder (%(min)s minutes)", g_GameAttributes.settings.VictoryDuration ), { "min": g_GameAttributes.settings.VictoryDuration } ); let isCaptureTheRelic = g_VictoryConditions.Name[victoryIdx] == "capture_the_relic"; if (isCaptureTheRelic) title = sprintf( translatePluralWithContext( "victory condition", "Capture The Relic (%(min)s minute)", "Capture The Relic (%(min)s minutes)", g_GameAttributes.settings.VictoryDuration ), { "min": g_GameAttributes.settings.VictoryDuration } ); titles.push({ "label": title, "value": g_VictoryConditions.Description[victoryIdx] }); if (isCaptureTheRelic) titles.push({ "label": translate("Relic Count"), "value": g_GameAttributes.settings.RelicCount }); } if (g_GameAttributes.settings.RatingEnabled && g_GameAttributes.settings.PlayerData.length == 2) titles.push({ "label": translate("Rated game"), "value": translate("When the winner of this match is determined, the lobby score will be adapted.") }); if (g_GameAttributes.settings.LockTeams) titles.push({ "label": translate("Locked Teams"), "value": translate("Players can't change the initial teams.") }); else titles.push({ "label": translate("Diplomacy"), "value": translate("Players can make alliances and declare war on allies.") }); if (g_GameAttributes.settings.LastManStanding) titles.push({ "label": translate("Last Man Standing"), "value": translate("Only one player can win the game. If the remaining players are allies, the game continues until only one remains.") }); else titles.push({ "label": translate("Allied Victory"), "value": translate("If one player wins, his or her allies win too. If one group of allies remains, they win.") }); if (extended) { titles.push({ "label": translate("Ceasefire"), "value": g_GameAttributes.settings.Ceasefire == 0 ? translate("disabled") : sprintf(translatePlural( "For the first minute, enemies will stay neutral.", "For the first %(min)s minutes, enemies will stay neutral.", g_GameAttributes.settings.Ceasefire), { "min": g_GameAttributes.settings.Ceasefire }) }); titles.push({ "label": translate("Map Name"), "value": translate(g_GameAttributes.settings.Name) }); titles.push({ "label": translate("Map Type"), "value": g_MapTypes.Title[g_MapTypes.Name.indexOf(g_GameAttributes.mapType)] }); if (g_GameAttributes.mapType == "random") { let mapSize = g_MapSizes.Name[g_MapSizes.Tiles.indexOf(g_GameAttributes.settings.Size)]; if (mapSize) titles.push({ "label": translate("Map Size"), "value": mapSize }); } } titles.push({ "label": translate("Map Description"), "value": g_GameAttributes.map == "random" ? translate("Randomly selects a map from the list") : g_GameAttributes.settings.Description ? translate(g_GameAttributes.settings.Description) : translate("Sorry, no description available."), }); if (extended) { titles.push({ "label": translate("Starting Resources"), "value": sprintf(translate("%(startingResourcesTitle)s (%(amount)s)"), { "startingResourcesTitle": g_StartingResources.Title[ g_StartingResources.Resources.indexOf( g_GameAttributes.settings.StartingResources)], "amount": g_GameAttributes.settings.StartingResources }) }); titles.push({ "label": translate("Population Limit"), "value": g_PopulationCapacities.Title[ g_PopulationCapacities.Population.indexOf( g_GameAttributes.settings.PopulationCap)] }); titles.push({ "label": translate("Disable Treasure"), "value": g_GameAttributes.settings.DisableTreasure }); titles.push({ "label": translate("Revealed Map"), "value": g_GameAttributes.settings.RevealMap }); titles.push({ "label": translate("Explored Map"), "value": g_GameAttributes.settings.ExploreMap }); titles.push({ "label": translate("Cheats"), "value": g_GameAttributes.settings.CheatsEnabled }); } return titles.map(title => sprintf(translate("%(label)s %(details)s"), { "label": "[color=\"" + g_DescriptionHighlight + "\"]" + title.label + ":" + "[/color]", "details": title.value === true ? translateWithContext("gamesetup option", "enabled") : !title.value ? translateWithContext("gamesetup option", "disabled") : title.value })).join("\n"); } /** * Sets the win/defeat icon to indicate current player's state. * @param {string} state - The current in-game state of the player. * @param {string} imageID - The name of the XML image object to update. */ function setOutcomeIcon(state, imageID) { let image = Engine.GetGUIObjectByName(imageID); if (state == "won") { image.sprite = "stretched:session/icons/victory.png"; image.tooltip = translate("Victorious"); } else if (state == "defeated") { image.sprite = "stretched:session/icons/defeat.png"; image.tooltip = translate("Defeated"); } } Index: ps/trunk/binaries/data/mods/public/gui/lobby/lobby.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/lobby/lobby.js (revision 19605) +++ ps/trunk/binaries/data/mods/public/gui/lobby/lobby.js (revision 19606) @@ -1,1425 +1,1491 @@ /** * Used for the gamelist-filtering. */ const g_MapSizes = prepareForDropdown(g_Settings && g_Settings.MapSizes); /** * Used for the gamelist-filtering. */ const g_MapTypes = prepareForDropdown(g_Settings && g_Settings.MapTypes); /** * Mute clients who exceed the rate of 1 message per second for this time */ const g_SpamBlockTimeframe = 5; /** * Mute spammers for this time. */ const g_SpamBlockDuration = 30; /** * A symbol which is prepended to the username of moderators. */ const g_ModeratorPrefix = "@"; /** * Current username. Cannot contain whitespace. */ const g_Username = Engine.LobbyGetNick(); /** * Current games will be listed in these colors. */ const g_GameColors = { "init": "0 219 0", "waiting": "255 127 0", "running": "219 0 0" }; /** * Initial sorting order of the gamelist. */ const g_GameStatusOrder = ["init", "waiting", "running"]; /** * The playerlist will be assembled using these values. */ const g_PlayerStatuses = { "available": { "color": "0 219 0", "status": translate("Online") }, "away": { "color": "229 76 13", "status": translate("Away") }, "playing": { "color": "200 0 0", "status": translate("Busy") }, "offline": { "color": "0 0 0", "status": translate("Offline") }, "unknown": { "color": "178 178 178", "status": translateWithContext("lobby presence", "Unknown") } }; const g_RoleNames = { "moderator": translate("Moderator"), "participant": translate("Player"), "visitor": translate("Muted Player") }; /** * Color for error messages in the chat. */ const g_SystemColor = "150 0 0"; /** * Color for private messages in the chat. */ const g_PrivateMessageColor = "0 150 0"; /** * Used for highlighting the sender of chat messages. */ const g_SenderFont = "sans-bold-13"; /** * Color to highlight chat commands in the explanation. */ const g_ChatCommandColor = "200 200 255"; /** * All chat messages received since init (i.e. after lobby join and after returning from a game). */ var g_ChatMessages = []; /** * Rating of the current user. * Contains the number or an empty string in case the user has no rating. */ var g_UserRating = ""; /** * All games currently running. */ var g_GameList = {}; /** * Remembers how many messages were sent by each user since the last reset. * * For example { "username": [numMessagesSinceReset, lastReset, timeBlocked] } */ var g_SpamMonitor = {}; /** * Used to restore the selection after updating the playerlist. */ var g_SelectedPlayer = ""; /** * Used to restore the selection after updating the gamelist. */ var g_SelectedGameIP = ""; /** * Used to restore the selection after updating the gamelist. */ var g_SelectedGamePort = ""; /** * Whether the current user has been kicked or banned. */ var g_Kicked = false; /** * Notifications sent by XmppClient.cpp */ var g_NetMessageTypes = { "system": { // Three cases are handled in prelobby.js "registered": msg => { }, "connected": msg => { }, "disconnected": msg => { updateGameList(); updateLeaderboard(); updatePlayerList(); Engine.GetGUIObjectByName("chatInput").hidden = true; for (let button of ["host", "leaderboard", "userprofile", "toggleBuddy"]) Engine.GetGUIObjectByName(button + "Button").enabled = false; Engine.GetGUIObjectByName("chatInput").hidden = true; if (!g_Kicked) addChatMessage({ "from": "system", "text": translate("Disconnected.") + " " + msg.text }); }, "error": msg => { addChatMessage({ "from": "system", "text": msg.text }); } }, "chat": { "subject": msg => { updateSubject(msg.text); }, "join": msg => { addChatMessage({ "text": "/special " + sprintf(translate("%(nick)s has joined."), { "nick": msg.text }), "isSpecial": true }); }, "leave": msg => { addChatMessage({ "text": "/special " + sprintf(translate("%(nick)s has left."), { "nick": msg.text }), "isSpecial": true }); if (msg.text == g_Username) Engine.DisconnectXmppClient(); }, "presence": msg => { }, "role": msg => { Engine.GetGUIObjectByName("chatInput").hidden = Engine.LobbyGetPlayerRole(g_Username) == "visitor"; let me = g_Username == msg.text; let role = Engine.LobbyGetPlayerRole(msg.text); let txt = role == "visitor" ? me ? translate("You have been muted.") : translate("%(nick)s has been muted.") : role == "moderator" ? me ? translate("You are now a moderator.") : translate("%(nick)s is now a moderator.") : msg.data == "visitor" ? me ? translate("You have been unmuted.") : translate("%(nick)s has been unmuted.") : me ? translate("You are not a moderator anymore.") : translate("%(nick)s is not a moderator anymore."); addChatMessage({ "text": "/special " + sprintf(txt, { "nick": msg.text }), "isSpecial": true }); // Update status information if that player is selected if (g_SelectedPlayer == msg.text) { let playersBox = Engine.GetGUIObjectByName("playersBox"); playersBox.selected = playersBox.list.indexOf(g_SelectedPlayer); } }, "nick": msg => { addChatMessage({ "text": "/special " + sprintf(translate("%(oldnick)s is now known as %(newnick)s."), { "oldnick": msg.text, "newnick": msg.data }), "isSpecial": true }); }, "kicked": msg => { handleKick(false, msg.text, msg.data || ""); }, "banned": msg => { handleKick(true, msg.text, msg.data || ""); }, "room-message": msg => { addChatMessage({ "from": escapeText(msg.from), "text": escapeText(msg.text), "datetime": msg.datetime }); }, "private-message": msg => { if (Engine.LobbyGetPlayerRole(msg.from) == "moderator") // some XMPP clients send trailing whitespace addChatMessage({ "from": escapeText(msg.from), "text": escapeText(msg.text.trim()), "datetime": msg.datetime, "private" : true }); } }, "game": { "gamelist": msg => updateGameList(), "profile": msg => updateProfile(), "leaderboard": msg => updateLeaderboard(), "ratinglist": msg => updatePlayerList() } }; /** * 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. */ var g_ChatCommands = { "away": { "description": translate("Set your state to 'Away'."), "handler": args => { Engine.LobbySetPlayerPresence("away"); return false; } }, "back": { "description": translate("Set your state to 'Online'."), "handler": args => { Engine.LobbySetPlayerPresence("available"); return false; } }, "kick": { "description": translate("Kick a specified user from the lobby. Usage: /kick nick reason"), "handler": args => { Engine.LobbyKick(args[0] || "", args[1] || ""); return false; }, "moderatorOnly": true }, "ban": { "description": translate("Ban a specified user from the lobby. Usage: /ban nick reason"), "handler": args => { Engine.LobbyBan(args[0] || "", args[1] || ""); return false; }, "moderatorOnly": true }, "help": { "description": translate("Show this help."), "handler": args => { let isModerator = Engine.LobbyGetPlayerRole(g_Username) == "moderator"; let text = translate("Chat commands:"); for (let command in g_ChatCommands) if (!g_ChatCommands[command].moderatorOnly || isModerator) // Translation: Chat command help format text += "\n" + sprintf(translate("%(command)s - %(description)s"), { "command": '[color="' + g_ChatCommandColor + '"]' + command + '[/color]', "description": g_ChatCommands[command].description }); addChatMessage({ "from": "system", "text": text }); return false; } }, "me": { "description": translate("Send a chat message about yourself. Example: /me goes swimming."), "handler": args => true }, "say": { "description": translate("Send text as a chat message (even if it starts with slash). Example: /say /help is a great command."), "handler": args => true }, "clear": { "description": translate("Clear all chat scrollback."), "handler": args => { clearChatMessages(); return false; } }, "quit": { "description": translate("Return to the main menu."), "handler": args => { returnToMainMenu(); return false; } } }; /** * Called after the XmppConnection succeeded and when returning from a game. * * @param {Object} attribs */ function init(attribs) { if (!g_Settings) { returnToMainMenu(); return; } initMusic(); global.music.setState(global.music.states.MENU); initGameFilters(); Engine.LobbySetPlayerPresence("available"); // When rejoining the lobby after a game, we don't need to process presence changes Engine.LobbyClearPresenceUpdates(); updatePlayerList(); updateSubject(Engine.LobbyGetRoomSubject()); + updateLobbyColumns(); Engine.GetGUIObjectByName("chatInput").tooltip = colorizeAutocompleteHotkey(); } +function updateLobbyColumns() +{ + let gameRating = Engine.ConfigDB_GetValue("user", "lobby.columns.gamerating") == "true"; + + // Only show the selected columns + let gamesBox = Engine.GetGUIObjectByName("gamesBox"); + gamesBox.hidden_mapType = gameRating; + gamesBox.hidden_gameRating = !gameRating; + + // Only show the filters of selected columns + let mapTypeFilter = Engine.GetGUIObjectByName("mapTypeFilter"); + mapTypeFilter.hidden = gameRating; + let gameRatingFilter = Engine.GetGUIObjectByName("gameRatingFilter"); + gameRatingFilter.hidden = !gameRating; + + // Keep filters right above the according column + let playersNumberFilter = Engine.GetGUIObjectByName("playersNumberFilter"); + let size = playersNumberFilter.size; + size.rleft = gameRating ? 74: 90; + size.rright = gameRating ? 84: 100; + playersNumberFilter.size = size; +} + function returnToMainMenu() { Engine.StopXmppClient(); Engine.SwitchGuiPage("page_pregame.xml"); } function initGameFilters() { let mapSizeFilter = Engine.GetGUIObjectByName("mapSizeFilter"); mapSizeFilter.list = [translateWithContext("map size", "Any")].concat(g_MapSizes.Name); mapSizeFilter.list_data = [""].concat(g_MapSizes.Tiles); let playersArray = Array(g_MaxPlayers).fill(0).map((v, i) => i + 1); // 1, 2, ... MaxPlayers let playersNumberFilter = Engine.GetGUIObjectByName("playersNumberFilter"); playersNumberFilter.list = [translateWithContext("player number", "Any")].concat(playersArray); playersNumberFilter.list_data = [""].concat(playersArray); let mapTypeFilter = Engine.GetGUIObjectByName("mapTypeFilter"); mapTypeFilter.list = [translateWithContext("map", "Any")].concat(g_MapTypes.Title); mapTypeFilter.list_data = [""].concat(g_MapTypes.Name); + let gameRatingOptions = ["<1000", "<1100","<1200",">1200",">1300",">1400",">1500"].reverse(); + gameRatingOptions = prepareForDropdown(gameRatingOptions.map(r => ({ + "value": r, + "label": sprintf( + r[0] == ">" ? + translateWithContext("gamelist filter", "> %(rating)s") : + translateWithContext("gamelist filter", "< %(rating)s"), + { "rating": r.substr(1) }) + }))) + + let gameRatingFilter = Engine.GetGUIObjectByName("gameRatingFilter"); + gameRatingFilter.list = [translateWithContext("map", "Any")].concat(gameRatingOptions.label); + gameRatingFilter.list_data = [""].concat(gameRatingOptions.value); + resetFilters(); } function resetFilters() { Engine.GetGUIObjectByName("mapSizeFilter").selected = 0; Engine.GetGUIObjectByName("playersNumberFilter").selected = 0; Engine.GetGUIObjectByName("mapTypeFilter").selected = g_MapTypes.Default; + Engine.GetGUIObjectByName("gameRatingFilter").selected = 0; Engine.GetGUIObjectByName("showFullFilter").checked = false; applyFilters(); } function applyFilters() { updateGameList(); updateGameSelection(); } /** * Filter a game based on the status of the filter dropdowns. * * @param {Object} game * @returns {boolean} - True if game should not be displayed. */ function filterGame(game) { let mapSizeFilter = Engine.GetGUIObjectByName("mapSizeFilter"); let playersNumberFilter = Engine.GetGUIObjectByName("playersNumberFilter"); let mapTypeFilter = Engine.GetGUIObjectByName("mapTypeFilter"); + let gameRatingFilter = Engine.GetGUIObjectByName("gameRatingFilter"); let 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.maxnbp != playersNumberFilter.list_data[playersNumberFilter.selected]) return true; if (mapTypeFilter.selected != 0 && game.mapType != mapTypeFilter.list_data[mapTypeFilter.selected]) return true; if (!showFullFilter.checked && game.maxnbp <= game.nbp) return true; + if (gameRatingFilter.selected > 0) + { + let selected = gameRatingFilter.list_data[gameRatingFilter.selected]; + if (selected.startsWith(">") && +selected.substr(1) >= game.gameRating || + selected.startsWith("<") && +selected.substr(1) <= game.gameRating) + return true; + } + return false; } function handleKick(banned, nick, reason) { let kickString = nick == g_Username ? banned ? translate("You have been banned from the lobby!") : translate("You have been kicked from the lobby!") : banned ? translate("%(nick)s has been banned from the lobby.") : translate("%(nick)s has been kicked from the lobby."); if (reason) reason = sprintf(translateWithContext("lobby kick", "Reason: %(reason)s"), { "reason": reason }); if (nick != g_Username) { addChatMessage({ "text": "/special " + sprintf(kickString, { "nick": nick }) + " " + reason, "isSpecial": true }); return; } addChatMessage({ "from": "system", "text": kickString + " " + reason, }); g_Kicked = true; Engine.DisconnectXmppClient(); messageBox( 400, 250, kickString + "\n" + reason, banned ? translate("BANNED") : translate("KICKED") ); } /** * Update the subject GUI object. * * @param {string} newSubject */ function updateSubject(newSubject) { Engine.GetGUIObjectByName("subject").caption = newSubject; // If the subject is only whitespace, hide it and reposition the logo. let subjectBox = Engine.GetGUIObjectByName("subjectBox"); subjectBox.hidden = !newSubject.trim(); let logo = Engine.GetGUIObjectByName("logo"); if (subjectBox.hidden) logo.size = "50%-110 50%-50 50%+110 50%+50"; else logo.size = "50%-110 40 50%+110 140"; } /** * Do a full update of the player listing, including ratings from cached C++ information. */ function updatePlayerList() { let playersBox = Engine.GetGUIObjectByName("playersBox"); let sortBy = playersBox.selected_column || "name"; let sortOrder = playersBox.selected_column_order || 1; if (playersBox.selected > -1) g_SelectedPlayer = playersBox.list[playersBox.selected]; let buddyStatusList = []; let playerList = []; let presenceList = []; let nickList = []; let ratingList = []; let cleanPlayerList = Engine.GetPlayerList().map(player => { player.isBuddy = g_Buddies.indexOf(player.name) != -1; return player; }).sort((a, b) => { let sortA, sortB; let statusOrder = Object.keys(g_PlayerStatuses); let statusA = statusOrder.indexOf(a.presence) + a.name.toLowerCase(); let statusB = statusOrder.indexOf(b.presence) + b.name.toLowerCase(); switch (sortBy) { case 'buddy': sortA = (a.isBuddy ? 1 : 2) + statusA; sortB = (b.isBuddy ? 1 : 2) + statusB; break; case 'rating': sortA = +a.rating; sortB = +b.rating; break; case 'status': sortA = statusA; sortB = statusB; break; case 'name': default: sortA = a.name.toLowerCase(); sortB = b.name.toLowerCase(); break; } if (sortA < sortB) return -sortOrder; if (sortA > sortB) return +sortOrder; return 0; }); // Colorize list entries for (let player of cleanPlayerList) { if (player.rating && player.name == g_Username) g_UserRating = player.rating; let rating = player.rating ? (" " + player.rating).substr(-5) : " -"; let presence = g_PlayerStatuses[player.presence] ? player.presence : "unknown"; if (presence == "unknown") warn("Unknown presence:" + player.presence); let statusColor = g_PlayerStatuses[presence].color; let coloredName = colorPlayerName((player.role == "moderator" ? g_ModeratorPrefix : "") + player.name); let coloredPresence = '[color="' + statusColor + '"]' + g_PlayerStatuses[presence].status + "[/color]"; let coloredRating = '[color="' + statusColor + '"]' + rating + "[/color]"; buddyStatusList.push(player.isBuddy ? '[color="' + statusColor + '"]' + g_BuddySymbol + '[/color]' : ""); playerList.push(coloredName); presenceList.push(coloredPresence); ratingList.push(coloredRating); nickList.push(player.name); } playersBox.list_buddy = buddyStatusList; playersBox.list_name = playerList; playersBox.list_status = presenceList; playersBox.list_rating = ratingList; playersBox.list = nickList; // To reduce rating-server load, only send the GUI event if the selection actually changed if (playersBox.selected != playersBox.list.indexOf(g_SelectedPlayer)) playersBox.selected = playersBox.list.indexOf(g_SelectedPlayer); } /** * Toggle buddy state for a player in playerlist within the user config */ function toggleBuddy() { let playerList = Engine.GetGUIObjectByName("playersBox"); let name = playerList.list[playerList.selected]; if (!name || name == g_Username || name.indexOf(g_BuddyListDelimiter) != -1) return; let index = g_Buddies.indexOf(name); if (index != -1) g_Buddies.splice(index, 1); else g_Buddies.push(name); // Don't save empty strings to the config file let buddies = g_Buddies.filter(nick => nick).join(g_BuddyListDelimiter) || g_BuddyListDelimiter; Engine.ConfigDB_CreateValue("user", "lobby.buddies", buddies); Engine.ConfigDB_WriteValueToFile("user", "lobby.buddies", buddies, "config/user.cfg"); updatePlayerList(); updateGameList(); } /** * Select the game listing the selected player when toggling the full games filter. */ function selectGameFromSelectedPlayername() { let playerList = Engine.GetGUIObjectByName("playersBox"); if (playerList.selected >= 0) selectGameFromPlayername(playerList.list[playerList.selected]); } /** * Select the game where the given player is currently playing, observing or offline. * Selects in that order to account for players that occur in multiple games. */ function selectGameFromPlayername(playerName) { let gameList = Engine.GetGUIObjectByName("gamesBox"); let foundAsObserver = false; for (let i = 0; i < g_GameList.length; ++i) for (let player of stringifiedTeamListToPlayerData(g_GameList[i].players)) { - let nick = removeRatingFromNick(player.Name); + let [nick, rating] = splitRatingFromNick(player.Name); if (playerName != nick) continue; if (player.Team == "observer") { foundAsObserver = true; gameList.selected = i; } else if (!player.Offline) { gameList.selected = i; return; } else if (!foundAsObserver) gameList.selected = i; } } function onPlayerListSelection() { lookupSelectedUserProfile("playersBox"); let playerList = Engine.GetGUIObjectByName("playersBox") if (playerList.selected != -1) selectGameFromPlayername(playerList.list[playerList.selected]); } function setLeaderboardVisibility(visible) { if (visible) Engine.SendGetBoardList(); lookupSelectedUserProfile(visible ? "leaderboardBox" : "playersBox"); Engine.GetGUIObjectByName("leaderboard").hidden = !visible; Engine.GetGUIObjectByName("fade").hidden = !visible; } function setUserProfileVisibility(visible) { Engine.GetGUIObjectByName("profileFetch").hidden = !visible; Engine.GetGUIObjectByName("fade").hidden = !visible; } /** * Display the profile of the player in the user profile window. */ function lookupUserProfile() { Engine.SendGetProfile(Engine.GetGUIObjectByName("fetchInput").caption); } /** * Display the profile of the selected player in the main window. * Displays N/A for all stats until updateProfile is called when the stats * are actually received from the bot. */ function lookupSelectedUserProfile(guiObjectName) { let playerList = Engine.GetGUIObjectByName(guiObjectName); let playerName = playerList.list[playerList.selected]; Engine.GetGUIObjectByName("profileArea").hidden = !playerName && !Engine.GetGUIObjectByName("usernameText").caption; if (!playerName) return; Engine.SendGetProfile(playerName); Engine.GetGUIObjectByName("usernameText").caption = playerName; Engine.GetGUIObjectByName("roleText").caption = g_RoleNames[Engine.LobbyGetPlayerRole(playerName)] 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() { let attributes = Engine.GetProfile()[0]; let user = colorPlayerName(attributes.player, attributes.rating); if (!Engine.GetGUIObjectByName("profileFetch").hidden) { let profileFound = attributes.rating != "-2"; Engine.GetGUIObjectByName("profileWindowArea").hidden = !profileFound; Engine.GetGUIObjectByName("profileErrorText").hidden = profileFound; if (!profileFound) { Engine.GetGUIObjectByName("profileErrorText").caption = sprintf( translate("Player \"%(nick)s\" not found."), { "nick": attributes.player } ); return; } Engine.GetGUIObjectByName("profileUsernameText").caption = user; Engine.GetGUIObjectByName("profileRankText").caption = attributes.rank; Engine.GetGUIObjectByName("profileHighestRatingText").caption = attributes.highestRating; Engine.GetGUIObjectByName("profileTotalGamesText").caption = attributes.totalGamesPlayed; Engine.GetGUIObjectByName("profileWinsText").caption = attributes.wins; Engine.GetGUIObjectByName("profileLossesText").caption = attributes.losses; Engine.GetGUIObjectByName("profileRatioText").caption = formatWinRate(attributes); return; } let playerList; if (!Engine.GetGUIObjectByName("leaderboard").hidden) playerList = Engine.GetGUIObjectByName("leaderboardBox"); else playerList = Engine.GetGUIObjectByName("playersBox"); if (attributes.rating == "-2") return; // Make sure the stats we have received coincide with the selected player. if (attributes.player != playerList.list[playerList.selected]) return; Engine.GetGUIObjectByName("usernameText").caption = user; Engine.GetGUIObjectByName("rankText").caption = attributes.rank; Engine.GetGUIObjectByName("highestRatingText").caption = attributes.highestRating; Engine.GetGUIObjectByName("totalGamesText").caption = attributes.totalGamesPlayed; Engine.GetGUIObjectByName("winsText").caption = attributes.wins; Engine.GetGUIObjectByName("lossesText").caption = attributes.losses; Engine.GetGUIObjectByName("ratioText").caption = formatWinRate(attributes); } /** * Update the leaderboard from data cached in C++. */ function updateLeaderboard() { let leaderboard = Engine.GetGUIObjectByName("leaderboardBox"); let boardList = Engine.GetBoardList().sort((a, b) => b.rating - a.rating); let list = []; let list_name = []; let list_rank = []; let list_rating = []; for (let i in boardList) { 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() { let gamesBox = Engine.GetGUIObjectByName("gamesBox"); let sortBy = gamesBox.selected_column; let sortOrder = gamesBox.selected_column_order; if (gamesBox.selected > -1) { g_SelectedGameIP = g_GameList[gamesBox.selected].ip; g_SelectedGamePort = g_GameList[gamesBox.selected].port; } g_GameList = Engine.GetGameList().map(game => { + game.hasBuddies = 0; + + // Compute average rating of participating players + let playerRatings = []; + for (let player of stringifiedTeamListToPlayerData(game.players)) { - let nick = removeRatingFromNick(player.Name); + let [nick, rating] = splitRatingFromNick(player.Name); + + if (player.Team != "observer") + playerRatings.push(rating); // Sort games with playing buddies above games with spectating buddies if (game.hasBuddies < 2 && g_Buddies.indexOf(nick) != -1) game.hasBuddies = player.Team == "observer" ? 1 : 2; } + game.gameRating = + playerRatings.length ? + Math.round(playerRatings.reduce((sum, current) => sum + current) / playerRatings.length) : + g_DefaultLobbyRating; + return game; }).filter(game => !filterGame(game)).sort((a, b) => { let sortA, sortB; switch (sortBy) { case 'name': sortA = g_GameStatusOrder.indexOf(a.state) + a.name.toLowerCase(); sortB = g_GameStatusOrder.indexOf(b.state) + b.name.toLowerCase(); break; + case 'gameRating': case 'mapSize': case 'mapType': sortA = a[sortBy]; sortB = b[sortBy]; break; case 'buddy': sortA = String(b.hasBuddies) + g_GameStatusOrder.indexOf(a.state) + a.name.toLowerCase(); sortB = String(a.hasBuddies) + g_GameStatusOrder.indexOf(b.state) + b.name.toLowerCase(); break; case 'mapName': sortA = translate(a.niceMapName); sortB = translate(b.niceMapName); break; case 'nPlayers': sortA = a.maxnbp; sortB = b.maxnbp; break; } if (sortA < sortB) return -sortOrder; if (sortA > sortB) return +sortOrder; return 0; }); let list_buddy = []; let list_name = []; let list_mapName = []; let list_mapSize = []; let list_mapType = []; let list_nPlayers = []; + let list_gameRating = []; let list = []; let list_data = []; let selectedGameIndex = -1; for (let i in g_GameList) { let game = g_GameList[i]; let gameName = escapeText(game.name); let mapTypeIdx = g_MapTypes.Name.indexOf(game.mapType); if (game.ip == g_SelectedGameIP && game.port == g_SelectedGamePort) selectedGameIndex = +i; list_buddy.push(game.hasBuddies ? '[color="' + g_GameColors[game.state] + '"]' + g_BuddySymbol + '[/color]' : ""); list_name.push('[color="' + g_GameColors[game.state] + '"]' + gameName); list_mapName.push(translateMapTitle(game.niceMapName)); list_mapSize.push(translateMapSize(game.mapSize)); list_mapType.push(g_MapTypes.Title[mapTypeIdx] || ""); list_nPlayers.push(game.nbp + "/" + game.maxnbp); + list_gameRating.push(game.gameRating); list.push(gameName); list_data.push(i); } gamesBox.list_buddy = list_buddy; 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_gameRating = list_gameRating; + // Change these last, otherwise crash gamesBox.list = list; gamesBox.list_data = list_data; gamesBox.selected = selectedGameIndex; updateGameSelection(); } /** * Populate the game info area with information on the current game selection. */ function updateGameSelection() { let game = selectedGame(); Engine.GetGUIObjectByName("gameInfo").hidden = !game; Engine.GetGUIObjectByName("joinGameButton").hidden = !game; Engine.GetGUIObjectByName("gameInfoEmpty").hidden = game; if (!game) return; Engine.GetGUIObjectByName("sgMapName").caption = translateMapTitle(game.niceMapName); let sgGameStartTime = Engine.GetGUIObjectByName("sgGameStartTime"); let sgNbPlayers = Engine.GetGUIObjectByName("sgNbPlayers"); let sgPlayersNames = Engine.GetGUIObjectByName("sgPlayersNames"); let playersNamesSize = sgPlayersNames.size; playersNamesSize.top = game.startTime ? sgGameStartTime.size.bottom : sgNbPlayers.size.bottom; playersNamesSize.rtop = game.startTime ? sgGameStartTime.size.rbottom : sgNbPlayers.size.rbottom; sgPlayersNames.size = playersNamesSize; sgGameStartTime.hidden = !game.startTime; if (game.startTime) sgGameStartTime.caption = sprintf( // Translation: %(time)s is the hour and minute here. translate("Game started at %(time)s"), { "time": Engine.FormatMillisecondsIntoDateStringLocal(+game.startTime*1000, translate("HH:mm")) }); sgNbPlayers.caption = sprintf( translate("Players: %(current)s/%(total)s"), { "current": game.nbp, "total": game.maxnbp }); sgPlayersNames.caption = formatPlayerInfo(stringifiedTeamListToPlayerData(game.players)); Engine.GetGUIObjectByName("sgMapSize").caption = translateMapSize(game.mapSize); let mapTypeIdx = g_MapTypes.Name.indexOf(game.mapType); Engine.GetGUIObjectByName("sgMapType").caption = g_MapTypes.Title[mapTypeIdx] || ""; let mapData = getMapDescriptionAndPreview(game.mapType, game.mapName); Engine.GetGUIObjectByName("sgMapDescription").caption = mapData.description; setMapPreviewImage("sgMapPreview", mapData.preview); } function selectedGame() { let gamesBox = Engine.GetGUIObjectByName("gamesBox"); if (gamesBox.selected < 0) return undefined; return g_GameList[gamesBox.list_data[gamesBox.selected]]; } /** * Immediately rejoin and join gamesetups. Otherwise confirm late-observer join attempt. */ function joinButton() { let game = selectedGame(); if (!game) return; let username = g_UserRating ? g_Username + " (" + g_UserRating + ")" : g_Username; if (game.state == "init" || stringifiedTeamListToPlayerData(game.players).some(player => player.Name == username)) 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, joinSelectedGame] ); } /** * Attempt to join the selected game without asking for confirmation. */ function joinSelectedGame() { let game = selectedGame(); if (!game) return; if (game.ip.split('.').length != 4) { addChatMessage({ "from": "system", "text": sprintf( translate("This game's address '%(ip)s' does not appear to be valid."), { "ip": game.ip } ) }); return; } Engine.PushGuiPage("page_gamesetup_mp.xml", { "multiplayerGameType": "join", "ip": game.ip, "port": game.port, "name": g_Username, "rating": g_UserRating }); } /** * Open the dialog box to enter the game name. */ function hostGame() { Engine.PushGuiPage("page_gamesetup_mp.xml", { "multiplayerGameType": "host", "name": g_Username, "rating": g_UserRating }); } /** * Processes GUI messages sent by the XmppClient. */ function onTick() { updateTimers(); checkSpamMonitor(); while (true) { let msg = Engine.LobbyGuiPollMessage(); if (!msg) break; if (!g_NetMessageTypes[msg.type]) { warn("Unrecognised message type: " + msg.type); continue; } if (!g_NetMessageTypes[msg.type][msg.level]) { warn("Unrecognised message level: " + msg.level); continue; } g_NetMessageTypes[msg.type][msg.level](msg); // To improve performance, only update the playerlist GUI when // the last update in the current stack is processed if (msg.type == "chat" && Engine.LobbyGetMucMessageCount() == 0) updatePlayerList(); } } /** * Executes a lobby command or sends GUI input directly as chat. */ function submitChatInput() { let input = Engine.GetGUIObjectByName("chatInput"); let text = input.caption; if (!text.length) return; if (handleChatCommand(text) && !isSpam(text, g_Username)) Engine.LobbySendMessage(text); input.caption = ""; } /** * Handle all '/' commands. * * @param {string} text - Text to be checked for commands. * @returns {boolean} true if the text should be sent via chat. */ function handleChatCommand(text) { if (text[0] != '/') return true; let [cmd, args] = ircSplit(text); args = ircSplit("/" + args); if (!g_ChatCommands[cmd]) { addChatMessage({ "from": "system", "text": sprintf( translate("The command '%(cmd)s' is not supported."), { "cmd": '[color="' + g_ChatCommandColor + '"]' + cmd + '[/color]' }) }); return false; } if (g_ChatCommands[cmd].moderatorOnly && Engine.LobbyGetPlayerRole(g_Username) != "moderator") { addChatMessage({ "from": "system", "text": sprintf( translate("The command '%(cmd)s' is restricted to moderators."), { "cmd": '[color="' + g_ChatCommandColor + '"]' + cmd + '[/color]' }) }); return false; } return g_ChatCommands[cmd].handler(args); } /** * Process and if appropriate, display a formatted message. * * @param {Object} msg - The message to be processed. */ function addChatMessage(msg) { if (msg.from) { if (Engine.LobbyGetPlayerRole(msg.from) == "moderator") msg.from = g_ModeratorPrefix + msg.from; // Highlight local user's nick if (g_Username != msg.from) { msg.text = msg.text.replace(g_Username, colorPlayerName(g_Username)); notifyUser(g_Username, msg.text); } // Run spam test if it's not a historical message if (!msg.datetime) { updateSpamMonitor(msg.from); if (isSpam(msg.text, msg.from)) return; } } let formatted = ircFormat(msg); if (!formatted) return; g_ChatMessages.push(formatted); Engine.GetGUIObjectByName("chatText").caption = g_ChatMessages.join("\n"); } /** * Splits given input into command and argument. */ function ircSplit(string) { let 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 {Object} msg - Received chat message. * @returns {string} - Formatted text. */ function ircFormat(msg) { let formattedMessage = ""; let coloredFrom = msg.from && colorPlayerName(msg.from); // Handle commands allowed past handleChatCommand. if (msg.text[0] == '/') { let [command, message] = ircSplit(msg.text); switch (command) { case "me": { // Translation: IRC message prefix when the sender uses the /me command. let senderString = sprintf(translate("* %(sender)s"), { "sender": coloredFrom }); // Translation: IRC message issued using the ‘/me’ command. formattedMessage = sprintf(translate("%(sender)s %(action)s"), { "sender": senderFont(senderString), "action": message }); break; } case "say": { // Translation: IRC message prefix. let senderString = sprintf(translate("<%(sender)s>"), { "sender": coloredFrom }); // Translation: IRC message. formattedMessage = sprintf(translate("%(sender)s %(message)s"), { "sender": senderFont(senderString), "message": message }); break; } case "special": { if (msg.isSpecial) // Translation: IRC system message. formattedMessage = senderFont(sprintf(translate("== %(message)s"), { "message": message })); else { // Translation: IRC message prefix. let senderString = sprintf(translate("<%(sender)s>"), { "sender": coloredFrom }); // Translation: IRC message. formattedMessage = sprintf(translate("%(sender)s %(message)s"), { "sender": senderFont(senderString), "message": message }); } break; } } } else { let senderString; // Translation: IRC message prefix. if (msg.private) senderString = sprintf(translateWithContext("lobby private message", "(%(private)s) <%(sender)s>"), { "private": '[color="' + g_PrivateMessageColor + '"]' + translate("Private") + '[/color]', "sender": coloredFrom }); else senderString = sprintf(translate("<%(sender)s>"), { "sender": coloredFrom }); // Translation: IRC message. formattedMessage = sprintf(translate("%(sender)s %(message)s"), { "sender": senderFont(senderString), "message": msg.text }); } // Add chat message timestamp if (Engine.ConfigDB_GetValue("user", "chat.timestamp") != "true") return formattedMessage; let time; if (msg.datetime) { let dTime = msg.datetime.split("T"); let parserDate = dTime[0].split("-"); let parserTime = dTime[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 let timeString = Engine.FormatMillisecondsIntoDateStringLocal(time.getTime(), translate("HH:mm")); // Translation: Time prefix as shown in the multiplayer lobby (when you enable it in the options page). let timePrefixString = sprintf(translate("\\[%(time)s]"), { "time": timeString }); // Translation: IRC message format when there is a time prefix. return sprintf(translate("%(time)s %(message)s"), { "time": senderFont(timePrefixString), "message": formattedMessage }); } /** * Update the spam monitor. * * @param {string} from - User to update. */ function updateSpamMonitor(from) { if (g_SpamMonitor[from]) ++g_SpamMonitor[from].count; else g_SpamMonitor[from] = { "count": 1, "lastSend": Math.floor(Date.now() / 1000), "lastBlock": 0 }; } /** * Check if a message is spam. * * @param {string} text - Body of message. * @param {string} from - Sender of message. * * @returns {boolean} - True if message should be blocked. */ function isSpam(text, from) { // Integer time in seconds. let time = Math.floor(Date.now() / 1000); // Initialize if not already in the database. if (!g_SpamMonitor[from]) g_SpamMonitor[from] = { "count": 1, "lastSend": time, "lastBlock": 0 }; // Block blank lines. if (!text.trim()) return true; // Block users who are still within their spam block period. if (g_SpamMonitor[from].lastBlock + g_SpamBlockDuration >= time) return true; // Block users who exceed the rate of 1 message per second for // five seconds and are not already blocked. if (g_SpamMonitor[from].count == g_SpamBlockTimeframe + 1) { g_SpamMonitor[from].lastBlock = time; if (from == g_Username) addChatMessage({ "from": "system", "text": translate("Please do not spam. You have been blocked for thirty seconds.") }); return true; } return false; } /** * Reset timer used to measure message send speed. * Clear message count every 5 seconds. */ function checkSpamMonitor() { let time = Math.floor(Date.now() / 1000); for (let i in g_SpamMonitor) { // Reset the spam-status after being silent long enough if (g_SpamMonitor[i].lastSend + g_SpamBlockTimeframe <= time) { g_SpamMonitor[i].count = 0; g_SpamMonitor[i].lastSend = time; } } } /** * 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 * @param {string} playername */ function getPlayerColor(playername) { if (playername == "system") return g_SystemColor; // 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(" "); } /** * Returns the given playername wrapped in an appropriate color-tag. * * @param {string} playername * @param {string} rating */ function colorPlayerName(playername, rating) { return '[color="' + getPlayerColor(playername.replace(g_ModeratorPrefix, "")) + '"]' + (rating ? sprintf( translate("%(nick)s (%(rating)s)"), { "nick": playername, "rating": rating }) : playername) + '[/color]'; } function senderFont(text) { return '[font="' + g_SenderFont + '"]' + text + "[/font]"; } function formatWinRate(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) }); } Index: ps/trunk/binaries/data/mods/public/gui/lobby/lobby.xml =================================================================== --- ps/trunk/binaries/data/mods/public/gui/lobby/lobby.xml (revision 19605) +++ ps/trunk/binaries/data/mods/public/gui/lobby/lobby.xml (revision 19606) @@ -1,373 +1,385 @@