Index: binaries/data/config/default.cfg =================================================================== --- binaries/data/config/default.cfg +++ binaries/data/config/default.cfg @@ -130,7 +130,7 @@ [adaptivefps] session = 60 ; Throttle FPS in running games (prevents 100% CPU workload). -menu = 30 ; Throttle FPS in menus only. +menu = 60 ; Throttle FPS in menus only. [hotkey] ; Each one of the specified keys will trigger the action on the left Index: binaries/data/mods/mod/gui/common/modern/sprites.xml =================================================================== --- binaries/data/mods/mod/gui/common/modern/sprites.xml +++ binaries/data/mods/mod/gui/common/modern/sprites.xml @@ -522,7 +522,7 @@ - Misc. - ========================================== --> - + - - - Index: binaries/data/mods/public/gui/common/gamedescription.js =================================================================== --- binaries/data/mods/public/gui/common/gamedescription.js +++ binaries/data/mods/public/gui/common/gamedescription.js @@ -65,7 +65,8 @@ let biomePreview = getBiomePreview(mapName, gameAttributes && gameAttributes.settings.Biome || ""); return deepfreeze({ - "description": mapData && mapData.settings && mapData.settings.Description ? translate(mapData.settings.Description) : translate("Sorry, no description available."), + "name": mapData && mapData.settings && mapData.settings.Name ? translate(mapData.settings.Name) : undefined, + "description": mapData && mapData.settings && mapData.settings.Description ? translate(mapData.settings.Description) : undefined, "preview": biomePreview ? biomePreview : mapData && mapData.settings && mapData.settings.Preview ? mapData.settings.Preview : "nopreview.png" }); @@ -231,13 +232,18 @@ */ function getGameDescription() { + if (!g_GameAttributes.settings) + return ""; + let titles = []; - if (!g_GameAttributes.settings.VictoryConditions.length) + if (!g_GameAttributes.settings.VictoryConditions || + !g_GameAttributes.settings.VictoryConditions.length) titles.push({ "label": translateWithContext("victory condition", "Endless Game"), "value": translate("No winner will be determined, even if everyone is defeated.") }); + if (g_GameAttributes.settings.VictoryConditions) for (let victoryCondition of g_VictoryConditions) { if (g_GameAttributes.settings.VictoryConditions.indexOf(victoryCondition.Name) == -1) @@ -320,6 +326,7 @@ "value": translate("If one player wins, his or her allies win too. If one group of allies remains, they win.") }); + if (g_GameAttributes.settings.Ceasefire !== undefined) titles.push({ "label": translate("Ceasefire"), "value": @@ -332,37 +339,37 @@ { "min": g_GameAttributes.settings.Ceasefire }) }); + if (g_GameAttributes.map !== undefined) + { if (g_GameAttributes.map == "random") titles.push({ "label": translateWithContext("Map Selection", "Random Map"), "value": translate("Randomly select a map from the list.") }); - else + else if (g_GameAttributes.mapType && g_GameAttributes.map) { + let mapData = getMapDescriptionAndPreview(g_GameAttributes.mapType, g_GameAttributes.map, g_GameAttributes); + + if (mapData.name) titles.push({ "label": translate("Map Name"), - "value": translate(g_GameAttributes.settings.Name) + "value": translate(mapData.name) }); + titles.push({ "label": translate("Map Description"), - "value": g_GameAttributes.settings.Description ? - translate(g_GameAttributes.settings.Description) : - translate("Sorry, no description available.") + "value": mapData.description }); } + } + if (g_GameAttributes.mapType) titles.push({ "label": translate("Map Type"), "value": g_MapTypes.Title[g_MapTypes.Name.indexOf(g_GameAttributes.mapType)] }); - if (typeof g_MapFilterList !== "undefined") - titles.push({ - "label": translate("Map Filter"), - "value": g_MapFilterList.name[g_MapFilterList.id.findIndex(id => id == g_GameAttributes.mapFilter)] - }); - - if (g_GameAttributes.mapType == "random") + if (g_GameAttributes.mapType == "random" && g_GameAttributes.settings.Size) { let mapSize = g_MapSizes.Name[g_MapSizes.Tiles.indexOf(g_GameAttributes.settings.Size)]; if (mapSize) @@ -390,6 +397,7 @@ }); } + if (g_GameAttributes.settings.Nomad !== undefined) titles.push({ "label": g_GameAttributes.settings.Nomad ? translate("Nomad Mode") : translate("Civic Centers"), "value": @@ -398,6 +406,7 @@ translate("Players start with a Civic Center.") }); + if (g_GameAttributes.settings.StartingResources !== undefined) titles.push({ "label": translate("Starting Resources"), "value": sprintf(translate("%(startingResourcesTitle)s (%(amount)s)"), { @@ -409,6 +418,7 @@ }) }); + if (g_GameAttributes.settings.PopulationCap !== undefined) titles.push({ "label": translate("Population Limit"), "value": @@ -417,6 +427,7 @@ g_GameAttributes.settings.PopulationCap)] }); + if (g_GameAttributes.settings.DisableTreasures !== undefined) titles.push({ "label": translate("Treasures"), "value": g_GameAttributes.settings.DisableTreasures ? @@ -424,16 +435,19 @@ translateWithContext("treasures", "As defined by the map.") }); + if (g_GameAttributes.settings.RevealMap !== undefined) titles.push({ "label": translate("Revealed Map"), "value": g_GameAttributes.settings.RevealMap }); + if (g_GameAttributes.settings.ExploreMap !== undefined) titles.push({ "label": translate("Explored Map"), "value": g_GameAttributes.settings.ExploreMap }); + if (g_GameAttributes.settings.CheatsEnabled !== undefined) titles.push({ "label": translate("Cheats"), "value": g_GameAttributes.settings.CheatsEnabled Index: binaries/data/mods/public/gui/gamesetup/Controls/GameSettingsControl.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/gamesetup/Controls/GameSettingsControl.js @@ -0,0 +1,223 @@ +/** + * This class provides a property independent interface to g_GameAttributes events. + * Classes may use this interface in order to react to changing g_GameAttributes. + */ +class GameSettingsControl +{ + constructor(gamesetupPage, netMessages, startGameControl, mapCache) + { + this.startGameControl = startGameControl; + this.mapCache = mapCache; + this.gameSettingsFile = new GameSettingsFile(gamesetupPage); + + this.previousMap = undefined; + this.depth = 0; + + // This property may be read from publicly + this.autostart = false; + + this.gameAttributesChangeHandlers = new Set(); + this.gameAttributesBatchChangeHandlers = new Set(); + this.gameAttributesFinalizeHandlers = new Set(); + this.assignPlayerHandlers = new Set(); + this.mapChangeHandlers = new Set(); + + gamesetupPage.registerLoadHandler(this.onLoad.bind(this)); + gamesetupPage.registerGetHotloadDataHandler(this.onGetHotloadData.bind(this)); + + startGameControl.registerLaunchGameHandler(this.onLaunchGame.bind(this)); + + if (g_IsNetworked) + netMessages.registerNetMessageHandler("gamesetup", this.onGamesetupMessage.bind(this)); + } + + registerMapChangeHandler(handler) + { + this.mapChangeHandlers.add(handler); + } + + registerGameAttributesChangeHandler(handler) + { + this.gameAttributesChangeHandlers.add(handler); + } + + registerGameAttributesBatchChangeHandler(handler) + { + this.gameAttributesBatchChangeHandlers.add(handler); + } + + registerGameAttributesFinalizeHandlers(handler) + { + this.gameAttributesFinalizeHandlers.add(handler); + } + + registerAssignPlayerHandler(handler) + { + this.assignPlayerHandlers.add(handler); + } + + unregisterAssignPlayerHandler(handler) + { + this.assignPlayerHandlers.delete(handler); + } + + onLoad(initData, hotloadData) + { + if (initData && initData.map && initData.mapType) + { + Object.defineProperty(this, "autostart", { + "value": true, + "writable": false, + "configurable": false + }); + + g_GameAttributes = { + "mapType": initData.mapType, + "map": initData.map + }; + + this.updateGameAttributes(); + // Don't launchGame before all Load handlers finished + } + else + { + if (hotloadData) + g_GameAttributes = hotloadData.gameAttributes; + else if (this.gameSettingsFile.enabled) + g_GameAttributes = this.gameSettingsFile.loadFile(); + + this.updateGameAttributes(); + this.setNetworkGameAttributes(); + } + } + + onGetHotloadData(object) + { + object.gameAttributes = g_GameAttributes; + } + + onGamesetupMessage(message) + { + if (!message.data) + return; + + g_GameAttributes = message.data; + this.updateGameAttributes(); + } + + /** + * This is to be called whenever g_GameAttributes has been changed. + */ + updateGameAttributes() + { + if (this.depth == 0) + Engine.ProfileStart("updateGameAttributes"); + + if (this.depth >= this.MaxDepth) + { + error("Infinite loop: " + new Error().stack); + return; + } + + ++this.depth; + + // Basic sanitization + { + if (!g_GameAttributes.settings) + g_GameAttributes.settings = {}; + + if (!g_GameAttributes.settings.PlayerData) + g_GameAttributes.settings.PlayerData = []; + + for (let i = 0; i < g_GameAttributes.settings.PlayerData.length; ++i) + if (!g_GameAttributes.settings.PlayerData[i]) + g_GameAttributes.settings.PlayerData[i] = {}; + } + + // Map change handlers are triggered first, so that GameSettingControls can update their + // gameAttributes model prior to applying that model in their gameAttributesChangeHandler. + if (g_GameAttributes.map && this.previousMap != g_GameAttributes.map && g_GameAttributes.mapType) + { + this.previousMap = g_GameAttributes.map; + let mapData = this.mapCache.getMapData(g_GameAttributes.mapType, g_GameAttributes.map); + for (let handler of this.mapChangeHandlers) + handler(mapData); + } + + for (let handler of this.gameAttributesChangeHandlers) + handler(); + + --this.depth; + + if (this.depth == 0) + { + for (let handler of this.gameAttributesBatchChangeHandlers) + handler(); + Engine.ProfileStop(); + } + } + + /** + * This function is to be called when a GUI control has initiated a value change. + * + * To avoid an infinite loop, do not call this function when a gamesetup message was + * received and the data had only been modified deterministically. + */ + setNetworkGameAttributes() + { + if (g_IsNetworked) + Engine.SetNetworkGameAttributes(g_GameAttributes); + } + + getPlayerData(gameAttributes, playerIndex) + { + return gameAttributes && + gameAttributes.settings && + gameAttributes.settings.PlayerData && + gameAttributes.settings.PlayerData[playerIndex] || undefined; + } + + assignPlayer(sourcePlayerIndex, playerIndex) + { + if (playerIndex == -1) + return; + + let target = this.getPlayerData(g_GameAttributes, playerIndex); + let source = this.getPlayerData(g_GameAttributes, sourcePlayerIndex); + + for (let handler of this.assignPlayerHandlers) + handler(source, target); + + this.updateGameAttributes(); + this.setNetworkGameAttributes(); + } + + onLaunchGame() + { + if (!this.autostart) + this.gameSettingsFile.saveFile(); + + // Used for identifying rated game reports for the lobby and possibly when sharing replays + g_GameAttributes.matchID = Engine.GetMatchID(); + + // Seed used for map generation and simulation + g_GameAttributes.settings.Seed = randIntExclusive(0, Math.pow(2, 32)); + g_GameAttributes.settings.AISeed = randIntExclusive(0, Math.pow(2, 32)); + + for (let handler of this.gameAttributesFinalizeHandlers) + handler(); + + // Copy playernames so they appear in replays + // Notice this is being done after selecting random Civs and Civ dependent playernames + for (let guid in g_PlayerAssignments) + { + let pData = g_GameAttributes.settings.PlayerData[g_PlayerAssignments[guid].player - 1]; + if (pData) + pData.Name = g_PlayerAssignments[guid].name; + } + + this.setNetworkGameAttributes(); + } +} + +GameSettingsControl.prototype.MaxDepth = 512; Index: binaries/data/mods/public/gui/gamesetup/Controls/GameSettingsFile.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/gamesetup/Controls/GameSettingsFile.js @@ -0,0 +1,56 @@ +/** + * This class provides a way to save g_GameAttributes to a file and load them. + */ +class GameSettingsFile +{ + constructor(gamesetupPage) + { + this.filename = g_IsNetworked ? + this.GameAttributesFileMultiplayer : + this.GameAttributesFileSingleplayer; + + this.engineInfo = Engine.GetEngineInfo(); + this.enabled = Engine.ConfigDB_GetValue("user", this.ConfigName) == "true"; + + gamesetupPage.registerClosePageHandler(this.saveFile.bind(this)); + } + + loadFile() + { + let data = + this.enabled && + g_IsController && + Engine.FileExists(this.filename) && + Engine.ReadJSONFile(this.filename); + + if (data && + data.attributes && + data.engine_info && + data.engine_info.engine_version == this.engineInfo.engine_version && + hasSameMods(data.engine_info.mods, this.engineInfo.mods)) + return data.attributes; + + return {}; + } + + /** + * Delete settings if disabled, so that players are not confronted with old settings after enabling the setting again. + */ + saveFile() + { + if (g_IsController) + Engine.WriteJSONFile(this.filename, { + "attributes": this.enabled ? g_GameAttributes : {}, + "engine_info": this.engineInfo + }); + } +} + +GameSettingsFile.prototype.ConfigName = + "persistmatchsettings"; + +GameSettingsFile.prototype.GameAttributesFileSingleplayer = + "config/matchsettings.json"; + +GameSettingsFile.prototype.GameAttributesFileMultiplayer = + "config/matchsettings.mp.json"; Index: binaries/data/mods/public/gui/gamesetup/Controls/MapCache.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/gamesetup/Controls/MapCache.js @@ -0,0 +1,155 @@ +/** + * This class obtains, caches and provides the gamesettings from map XML and JSON files. + */ +class MapCache +{ + constructor() + { + this.mapFilters = Engine.ReadJSONFile(this.FiltersFile); + translateObjectKeys(this.mapFilters, ["Name", "Tooltip"]); + + this.cache = {}; + } + + getMapTypePath(mapTypeName) + { + if (!this.MapTypes[mapTypeName]) + { + error("Can't get filtered maps for invalid maptype: " + mapTypeName); + return undefined; + } + return this.MapTypes[mapTypeName].path; + } + + getMapData(mapTypeName, mapPath) + { + if (!mapPath || mapPath == "random") + return undefined; + + if (!this.cache[mapPath]) + { + Engine.ProfileStart("getMapData"); + let mapData = mapTypeName == "random" ? + Engine.ReadJSONFile(mapPath + ".json") : + Engine.LoadMapSettings(mapPath); + + // Remove gaia, TODO: Maps should be consistent + if (mapData && + mapData.settings && + mapData.settings.PlayerData && + mapData.settings.PlayerData.length && + !mapData.settings.PlayerData[0]) + { + mapData.settings.PlayerData.shift(); + } + + this.cache[mapPath] = mapData; + Engine.ProfileStop(); + } + + return this.cache[mapPath]; + } + + /** + * Doesn't translate, so that networked and lobby page viewers can do that locally. + * The result is to be used with translateMapTitle. + */ + getTranslatableMapName(mapTypeName, mapPath) + { + if (mapPath == "random") + return "random"; + + let mapData = this.getMapData(mapTypeName, mapPath); + if (!mapData || !mapData.settings || !mapData.settings.Name) + return undefined; + + return mapData.settings.Name; + } + + /** + * Some map filters may reject every map of a particular mapType. + * This function allows identifying which map filters have any matches for that maptype. + */ + getAvailableMapFilters(mapTypeName) + { + return this.mapFilters.filter(filter => + this.getFilteredMaps(mapTypeName, filter.Name, true)); + } + + /** + * This function identifies all maps matching the given mapType and mapFilter. + * If existence is true, it will only test if there is at least one file for that mapType and mapFilter. + * Otherwise it returns an array with filename, translated map title and map description. + */ + getFilteredMaps(mapTypeName, filterName, existence) + { + let mapType = this.MapTypes[mapTypeName] || undefined; + if (!mapType) + { + error("Can't get filtered maps for invalid maptype: " + mapTypeName); + return undefined; + } + + let mapFilter = this.mapFilters.find(filter => filter.Name == filterName); + if (!mapFilter) + { + error("Invalid mapfilter name: " + filterName); + return undefined; + } + + let maps = []; + for (let filename of listFiles(mapType.path, mapType.suffix, false)) + { + if (filename.startsWith(this.HiddenFilesPrefix)) + continue; + + let mapPath = mapType.path + filename; + let mapData = this.getMapData(mapTypeName, mapPath); + + // Map files may come with custom json files + if (!mapData || !mapData.settings) + continue; + + if (MatchesClassList(mapData.settings.Keywords || [], mapFilter.Match)) + { + if (existence) + return true; + + maps.push({ + "file": mapPath, + "name": translate(mapData.settings.Name), + "description": translate(mapData.settings.Description) + }); + } + } + return existence ? false : maps; + } +} + +/** + * Directory containing all maps of the given type. + */ +MapCache.prototype.MapTypes = { + "scenario": { + "path":"maps/scenarios/", + "suffix": ".xml", + }, + "skirmish": { + "path": "maps/skirmishes/", + "suffix": ".xml" + }, + "random": { + "path": "maps/random/", + "suffix": ".json" + } +}; + +MapCache.prototype.FiltersFile = + "gui/gamesetup/Controls/MapFilters.json"; + +/** + * When maps start with this prefix, they will not appear in the maplist. + * Used for the Atlas _default.xml for instance. + */ +MapCache.prototype.HiddenFilesPrefix = + "_"; Index: binaries/data/mods/public/gui/gamesetup/Controls/PlayerAssignmentsControl.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/gamesetup/Controls/PlayerAssignmentsControl.js @@ -0,0 +1,197 @@ +/** + * This class provides a property independent interface to g_PlayerAssignment events and actions. + */ +class PlayerAssignmentsControl +{ + constructor(gamesetupPage, netMessages) + { + this.clientJoinHandlers = new Set(); + this.clientLeaveHandlers = new Set(); + this.playerAssignmentsChangeHandlers = new Set(); + + if (!g_IsNetworked) + { + let name = singleplayerName(); + + // Replace empty player name when entering a single-player match for the first time. + Engine.ConfigDB_CreateAndWriteValueToFile("user", this.ConfigNameSingleplayer, name, "config/user.cfg"); + + g_PlayerAssignments = { + "local": { + "name": name, + "player": -1 + } + }; + } + + gamesetupPage.registerLoadHandler(this.onLoad.bind(this)); + gamesetupPage.registerGetHotloadDataHandler(this.onGetHotloadData.bind(this)); + netMessages.registerNetMessageHandler("players", this.onPlayerAssignmentMessage.bind(this)); + this.registerClientJoinHandler(this.onClientJoin.bind(this)); + } + + registerPlayerAssignmentsChangeHandler(handler) + { + this.playerAssignmentsChangeHandlers.add(handler); + } + + unregisterPlayerAssignmentsChangeHandler(handler) + { + this.playerAssignmentsChangeHandlers.delete(handler); + } + + registerClientJoinHandler(handler) + { + this.clientJoinHandlers.add(handler); + } + + unregisterClientJoinHandler(handler) + { + this.clientJoinHandlers.delete(handler); + } + + registerClientLeaveHandler(handler) + { + this.clientLeaveHandlers.add(handler); + } + + unregisterClientLeaveHandler(handler) + { + this.clientLeaveHandlers.delete(handler); + } + + onLoad(initData, hotloadData) + { + if (hotloadData) + { + g_PlayerAssignments = hotloadData.playerAssignments; + this.updatePlayerAssignments(); + } + else if (!g_IsNetworked) + this.onClientJoin("local", g_PlayerAssignments); + } + + onGetHotloadData(object) + { + object.playerAssignments = g_PlayerAssignments; + } + + /** + * To be called when g_PlayerAssignments is modified. + */ + updatePlayerAssignments() + { + Engine.ProfileStart("updateGameAttributes"); + for (let handler of this.playerAssignmentsChangeHandlers) + handler(); + Engine.ProfileStop(); + } + + /** + * Called whenever a client joins/leaves or any gamesetting is changed. + */ + onPlayerAssignmentMessage(message) + { + let newAssignments = message.newAssignments; + for (let guid in newAssignments) + if (!g_PlayerAssignments[guid]) + for (let handler of this.clientJoinHandlers) + handler(guid, message.newAssignments); + + for (let guid in g_PlayerAssignments) + if (!newAssignments[guid]) + for (let handler of this.clientLeaveHandlers) + handler(guid); + + g_PlayerAssignments = newAssignments; + this.updatePlayerAssignments(); + } + + onClientJoin(newGUID, newAssignments) + { + if (newAssignments[newGUID].player != -1) + return; + + // Assign the client (or only buddies if prefered) to a free slot + if (newGUID != Engine.GetPlayerGUID()) + { + let assignOption = Engine.ConfigDB_GetValue("user", this.ConfigAssignPlayers); + if (assignOption == "disabled" || + assignOption == "buddies" && g_Buddies.indexOf(splitRatingFromNick(newAssignments[newGUID].name).nick) == -1) + return; + } + + let freeSlot = g_GameAttributes.settings.PlayerData.findIndex((v, i) => + Object.keys(g_PlayerAssignments).every(guid => g_PlayerAssignments[guid].player != i + 1)); + + if (freeSlot != -1) + if (g_IsController) + { + if (g_IsNetworked) + Engine.AssignNetworkPlayer(freeSlot + 1, newGUID); + else + { + g_PlayerAssignments[newGUID].player = freeSlot + 1; + this.updatePlayerAssignments(); + } + } + } + + assignClient(guid, playerIndex) + { + if (g_IsNetworked) + Engine.AssignNetworkPlayer(playerIndex, guid); + else + { + g_PlayerAssignments[guid].player = playerIndex; + this.updatePlayerAssignments(); + } + } + + /** + * If both clients are assigned players, this will swap their assignments. + */ + assignPlayer(guidToAssign, playerIndex) + { + if (g_PlayerAssignments[guidToAssign].player != -1) + for (let guid in g_PlayerAssignments) + if (g_PlayerAssignments[guid].player == playerIndex + 1) + { + this.assignClient(guid, g_PlayerAssignments[guidToAssign].player); + break; + } + + this.assignClient(guidToAssign, playerIndex + 1); + + if (!g_IsNetworked) + this.updatePlayerAssignments(); + } + + unassignClient(playerID) + { + if (g_IsNetworked) + Engine.AssignNetworkPlayer(playerID, ""); + else if (g_PlayerAssignments.local.player == playerID) + { + g_PlayerAssignments.local.player = -1; + this.updatePlayerAssignments(); + } + } + + unassignInvalidPlayers() + { + if (g_IsNetworked) + for (let playerID = g_GameAttributes.settings.PlayerData.length + 1; playerID <= g_MaxPlayers; ++playerID) + // Remove obsolete playerIDs from the servers playerassignments copy + Engine.AssignNetworkPlayer(playerID, ""); + + else if (g_PlayerAssignments.local.player > g_MaxPlayers) + g_PlayerAssignments.local.player = -1; + } +} + +PlayerAssignmentsControl.prototype.ConfigAssignPlayers = + "gui.gamesetup.assignplayers"; + +PlayerAssignmentsControl.prototype.ConfigNameSingleplayer = + "playername.singleplayer"; Index: binaries/data/mods/public/gui/gamesetup/Controls/ReadyControl.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/gamesetup/Controls/ReadyControl.js @@ -0,0 +1,126 @@ +/** + * Ready system: + * + * The ready mechanism protects the players from being assigned to a match with settings they didn't explicitly agree with. + * It shall be technically possible to start a networked game until all participating players formally agree with the chosen settings. + * + * Therefore assume the readystate from the user interface rather than trusting the server whether the current player is ready. + * The server may set readiness to false but not to true. + * + * The ReadyControl class stores the ready state of the current player and fires an event if the agreed settings changed. + */ +class ReadyControl +{ + constructor(netMessages, gameSettingsControl, playerAssignmentsControl) + { + this.playerAssignmentsControl = playerAssignmentsControl; + + this.resetReadyHandlers = new Set(); + this.previousAssignments = {}; + + // This variable keeps track whether the local player is ready + // As part of cheat prevention, the server may set this to NotReady, but + // only the UI may set it to Ready or StayReady. + this.readyState = this.NotReady; + + netMessages.registerNetMessageHandler("ready", this.onReadyMessage.bind(this)); + + gameSettingsControl.registerGameAttributesBatchChangeHandler( + this.onGameAttributesBatchChange.bind(this)); + + playerAssignmentsControl.registerClientJoinHandler(this.onClientJoin.bind(this)); + playerAssignmentsControl.registerClientLeaveHandler(this.onClientLeave.bind(this)); + } + + registerResetReadyHandler(handler) + { + this.resetReadyHandlers.add(handler); + } + + onClientJoin(newGUID, newAssignments) + { + if (newAssignments[newGUID].player != -1) + this.resetReady(); + } + + onClientLeave(guid) + { + if (g_PlayerAssignments[guid].player != -1) + this.resetReady(); + } + + onReadyMessage(message) + { + let playerAssignment = g_PlayerAssignments[message.guid]; + if (playerAssignment) + { + playerAssignment.status = message.status; + this.playerAssignmentsControl.updatePlayerAssignments(); + } + } + + onPlayerAssignmentsChange() + { + // Don't let the host tell you that you're ready when you're not. + let playerAssignment = g_PlayerAssignments[Engine.GetPlayerGUID()]; + if (playerAssignment && playerAssignment.status > this.readyState) + playerAssignment.status = this.readyState; + + for (let guid in g_PlayerAssignments) + if (this.previousAssignments[guid] && + this.previousAssignments[guid].player != g_PlayerAssignments[guid].player) + { + this.resetReady(); + return; + } + } + + onGameAttributesBatchChange() + { + this.resetReady(); + } + + setReady(ready, sendMessage) + { + this.readyState = ready; + + if (sendMessage) + Engine.SendNetworkReady(ready); + + // Update GUI objects instantly if relevant settingchange was detected + let playerAssignment = g_PlayerAssignments[Engine.GetPlayerGUID()]; + if (playerAssignment) + { + playerAssignment.status = ready; + this.playerAssignmentsControl.updatePlayerAssignments(); + } + } + + resetReady() + { + if (!g_IsNetworked) + return; + + for (let handler of this.resetReadyHandlers) + handler(); + + if (g_IsController) + { + Engine.ClearAllPlayerReady(); + this.playerAssignmentsControl.updatePlayerAssignments(); + } + else if (this.readyState != this.StayReady) + this.setReady(this.NotReady, false); + } + + getLocalReadyState() + { + return this.readyState; + } +} + +ReadyControl.prototype.NotReady = 0; + +ReadyControl.prototype.Ready = 1; + +ReadyControl.prototype.StayReady = 2; Index: binaries/data/mods/public/gui/gamesetup/Controls/StartGameControl.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/gamesetup/Controls/StartGameControl.js @@ -0,0 +1,48 @@ +/** + * Cheat prevention: + * + * 1. Ensure that the host cannot start the game unless all clients agreed on the gamesettings using the ready system. + * + * TODO: + * 2. Ensure that the host cannot start the game with GameAttributes different from the agreed ones. + * This may be achieved by: + * - Determining the seed collectively. + * - passing the agreed gamesettings to the engine when starting the game instance + * - rejecting new gamesettings from the server after the game launch event + */ +class StartGameControl +{ + constructor(netMessages) + { + this.gameLaunchHandlers = new Set(); + + netMessages.registerNetMessageHandler("start", this.switchToLoadingPage.bind(this)); + } + + registerLaunchGameHandler(handler) + { + this.gameLaunchHandlers.add(handler); + } + + launchGame() + { + for (let handler of this.gameLaunchHandlers) + handler(); + + if (g_IsNetworked) + Engine.StartNetworkGame(); + else + { + Engine.StartGame(g_GameAttributes, g_PlayerAssignments.local.player); + this.switchToLoadingPage(); + } + } + + switchToLoadingPage() + { + Engine.SwitchGuiPage("page_loading.xml", { + "attribs": g_GameAttributes, + "playerAssignments": g_PlayerAssignments + }); + } +} Index: binaries/data/mods/public/gui/gamesetup/GameSettings/GameSettingControl.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/gamesetup/GameSettings/GameSettingControl.js @@ -0,0 +1,160 @@ +/** + * The GameSettingControl is an abstract class that is inherited by gamesetting control classes specific to a GUI object type, + * such as the GameSettingControlCheckbox or GameSettingControlDropdown. + * + * These classes are abstract classes too and are implemented by each handler class specific to one logical setting of g_GameAttributes. + * The purpose of these classes is to control precisely one logical setting of g_GameAttributes. + * Having one class per logical setting allows to handle each settings without making a restriction as to how the property should be written to g_GameAttributes or g_PlayerAssignments. + * The base classes allow implementing that while avoiding duplication. + * + * A GameSettingControl may depend on and read from other g_GameAttribute values, + * but the class instance is to be the sole instance writing to its setting value in g_GameAttributes and + * shall not write to setting values of other logical settings. + * + * The derived classes shall not make assumptions on the validity of g_GameAttributes, + * sanitize or delete their value if it is incompatible. + * + * A class should only write values to g_GameAttributes that it itself has confirmed to be accurate. + * This means that handlers may not copy an entire object or array of values, for example on mapchange. + * This avoids writing a setting value to g_GameAttributes that is not tracked and deleted when it becomes invalid. + * + * Since GameSettingControls shall be able to subscribe to g_GameAttributes changes, + * it is an obligation of the derived GameSettingControl class to broadcast the GameAttributesChange event each time it changes g_GameAttributes. + */ +class GameSettingControl +{ + // The constructor and inherited constructors shall not modify game attributes, + // since all GameSettingControl shall be able to subscribe to any gamesetting change. + constructor(gameSettingControlManager, category, playerIndex, gameSettingsControl, mapCache, netMessages, playerAssignmentsControl) + { + this.category = category; + this.mapCache = mapCache; + this.netMessages = netMessages; + this.playerAssignmentsControl = playerAssignmentsControl; + this.gameSettingsControl = gameSettingsControl; + + // enabled and hidden should only be modified through their setters or + // by calling updateVisibility after modification. + this.enabled = true; + this.hidden = false; + + if (playerIndex !== undefined) + this.playerIndex = playerIndex; + + this.setControl(gameSettingControlManager); + + // This variable also used for autocompleting chat. + this.autocompleteTitle = undefined; + if (this.title && this.TitleCaption) + this.setTitle(this.TitleCaption); + + if (this.Tooltip) + this.setTooltip(this.Tooltip) + + this.setHidden(false); + + if (this.onMapChange) + gameSettingsControl.registerMapChangeHandler(this.onMapChange.bind(this)); + + // Derived classes are supposed to examine whether relevant settings have changed and take action accordingly. + // This includes: + // - selecting a default if the setting is available but not set + // - delete obsolete or invalid values + // - updating the GUI control value, hidden, enabled and tooltip properties + // - informing the other setting controls if a setting has been changed + if (this.onGameAttributesChange) + gameSettingsControl.registerGameAttributesChangeHandler(this.onGameAttributesChange.bind(this)); + + if (this.onPlayerAssignmentsChange) + playerAssignmentsControl.registerPlayerAssignmentsChangeHandler(this.onPlayerAssignmentsChange.bind(this)); + + if (this.onGameAttributesFinalize) + gameSettingsControl.registerGameAttributesFinalizeHandlers(this.onGameAttributesFinalize.bind(this)); + } + + setTitle(titleCaption) + { + this.autocompleteTitle = titleCaption; + this.title.caption = sprintf(this.TitleCaptionFormat, { + "setting": titleCaption + }); + } + + setTooltip(tooltip) + { + if (this.title) + this.title.tooltip = tooltip; + + if (this.label) + this.label.tooltip = tooltip; + + this.setControlTooltip(tooltip); + } + + setEnabled(enabled) + { + this.enabled = enabled; + this.updateVisibility() + } + + setHidden(hidden) + { + this.hidden = hidden; + this.updateVisibility() + } + + updateVisibility() + { + let hidden = + this.hidden || + this.playerIndex === undefined && + this.category != g_TabCategorySelected || + this.playerIndex !== undefined && + g_GameAttributes.settings && this.playerIndex >= g_GameAttributes.settings.PlayerData.length; + + if (this.frame) + this.frame.hidden = hidden; + + if (hidden) + return; + + let enabled = g_IsController && this.enabled; + + this.setControlHidden(!enabled) + + if (this.label) + this.label.hidden = !!enabled; + } + + /** + * Returns whether the control specifies an order but didn't implement the function. + */ + addAutocompleteEntries(name, autocomplete) + { + if (this.autocompleteTitle) + autocomplete[0].push(this.autocompleteTitle); + + if (!Number.isInteger(this.AutocompleteOrder)) + return; + + if (!this.getAutocompleteEntries) + { + error(name + " specifies AutocompleteOrder but didn't implement getAutocompleteEntries"); + return; + } + + let newEntries = this.getAutocompleteEntries(); + if (newEntries) + autocomplete[this.AutocompleteOrder] = + (autocomplete[this.AutocompleteOrder] || []).concat(newEntries); + } +} + +GameSettingControl.prototype.TitleCaptionFormat = + translateWithContext("Title for specific setting", "%(setting)s:"); + +/** + * Derived classes can set this to a number to enable chat autocompleting of setting values. + * Higher numbers are autocompleted first. + */ +GameSettingControl.prototype.AutocompleteOrder = undefined; Index: binaries/data/mods/public/gui/gamesetup/GameSettings/GameSettingControlCheckbox.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/gamesetup/GameSettings/GameSettingControlCheckbox.js @@ -0,0 +1,60 @@ +/** + * This class is implemented by gamesettings that are controlled by a checkbox. + */ +class GameSettingControlCheckbox extends GameSettingControl +{ + constructor(...args) + { + super(...args); + + this.isInGuiUpdate = false; + this.previousSelectedValue = undefined; + } + + setControl(gameSettingControlManager) + { + let row = gameSettingControlManager.getNextRow("checkboxSettingFrame"); + this.frame = Engine.GetGUIObjectByName("checkboxSettingFrame[" + row + "]"); + this.checkbox = Engine.GetGUIObjectByName("checkboxSettingControl[" + row + "]"); + this.checkbox.onPress = this.onPressSuper.bind(this); + + let labels = this.frame.children[0].children; + this.title = labels[0]; + this.label = labels[1]; + } + + setControlTooltip(tooltip) + { + this.checkbox.tooltip = tooltip; + } + + setControlHidden(hidden) + { + this.checkbox.hidden = hidden; + } + + setChecked(checked) + { + if (this.previousSelectedValue == checked) + return; + + this.isInGuiUpdate = true; + this.checkbox.checked = checked; + this.isInGuiUpdate = false; + + if (this.label) + this.label.caption = checked ? this.Checked : this.Unchecked; + } + + onPressSuper() + { + if (!this.isInGuiUpdate) + this.onPress(this.checkbox.checked); + } +} + +GameSettingControlCheckbox.prototype.Checked = + translate("Yes"); + +GameSettingControlCheckbox.prototype.Unchecked = + translate("No"); Index: binaries/data/mods/public/gui/gamesetup/GameSettings/GameSettingControlCheckbox.xml =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/gamesetup/GameSettings/GameSettingControlCheckbox.xml @@ -0,0 +1,14 @@ + +