Index: ps/trunk/binaries/data/mods/public/gui/campaigns/default_menu/CampaignMenu.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/campaigns/default_menu/CampaignMenu.js (revision 25635) +++ ps/trunk/binaries/data/mods/public/gui/campaigns/default_menu/CampaignMenu.js (revision 25636) @@ -1,190 +1,214 @@ /** * This is the main menu screen of the campaign. * It shows you the currently available scenarios, scenarios you've already completed, etc. * This particular variant is extremely simple and shows a list similar to Age 1's campaigns, * but conceptually nothing really prevents more complex systems. */ class CampaignMenu extends AutoWatcher { constructor(campaignRun) { super("render"); this.run = campaignRun; this.selectedLevel = -1; this.levelSelection = Engine.GetGUIObjectByName("levelSelection"); this.levelSelection.onSelectionChange = () => { this.selectedLevel = this.levelSelection.selected; }; this.levelSelection.onMouseLeftDoubleClickItem = () => this.startScenario(); Engine.GetGUIObjectByName('startButton').onPress = () => this.startScenario(); Engine.GetGUIObjectByName('backToMain').onPress = () => this.goBackToMainMenu(); Engine.GetGUIObjectByName('savedGamesButton').onPress = () => Engine.PushGuiPage('page_loadgame.xml', { 'campaignRun': this.run.filename }); this.mapCache = new MapCache(); this._ready = true; } goBackToMainMenu() { this.run.save(); Engine.SwitchGuiPage("page_pregame.xml", {}); } startScenario() { - let level = this.getSelectedLevelData(); + const level = this.getSelectedLevelData(); if (!meetsRequirements(this.run, level)) return; - let settings = { + const settings = { "mapType": level.MapType, "map": "maps/" + level.Map, "settings": { "CheatsEnabled": true }, "campaignData": { "run": this.run.filename, "levelID": this.levelSelection.list_data[this.selectedLevel], "data": this.run.data } }; - let assignments = { + const assignments = { "local": { "player": 1, "name": Engine.ConfigDB_GetValue("user", "playername.singleplayer") || Engine.GetSystemUsername() } }; - let gameSettings = new GameSettings().init(); + const gameSettings = new GameSettings().init(); gameSettings.fromInitAttributes(settings); + if (level.Preview) gameSettings.mapPreview.setCustom("cropped:" + 400/512 + "," + 300/512 + ":" + level.Preview); gameSettings.mapName.set(this.getLevelName(level)); // TODO: level description should also be passed, ideally. + if (level.useGameSetup) + { + // Setup some default AI on the non-human players. + for (let i = 1; i < gameSettings.playerCount.nbPlayers; ++i) + gameSettings.playerAI.set(i, { + "bot": g_Settings.PlayerDefaults[i + 1].AI, + "difficulty": +Engine.ConfigDB_GetValue("user", "gui.gamesetup.aidifficulty"), + "behavior": Engine.ConfigDB_GetValue("user", "gui.gamesetup.aibehavior"), + }); + + const attributes = gameSettings.toInitAttributes(); + attributes.guiData = { + "lockSettings": { + "map": true, + }, + }; + + Engine.SwitchGuiPage("page_gamesetup.xml", { + "gameSettings": attributes, + }); + return; + } + gameSettings.launchGame(assignments); Engine.SwitchGuiPage("page_loading.xml", { "attribs": gameSettings.toInitAttributes(), "playerAssignments": assignments }); } getSelectedLevelData() { if (this.selectedLevel === -1) return undefined; return this.run.template.Levels[this.levelSelection.list_data[this.selectedLevel]]; } shouldShowLevel(levelData) { if (this.run.template.ShowUnavailable) return true; return meetsRequirements(this.run, levelData); } getLevelName(levelData) { if (levelData.Name) return translateWithContext("Campaign Template", levelData.Name); return translate(this.mapCache.getTranslatableMapName(levelData.MapType, "maps/" + levelData.Map)); } getLevelDescription(levelData) { if (levelData.Description) return translateWithContext("Campaign Template", levelData.Description); return this.mapCache.getTranslatedMapDescription(levelData.MapType, "maps/" + levelData.Map); } getLevelPreview(levelData) { if (levelData.Preview) return "cropped:" + 400/512 + "," + 300/512 + ":" + levelData.Preview; return this.mapCache.getMapPreview(levelData.MapType, "maps/" + levelData.Map); } displayLevelsList() { let list = []; for (let key in this.run.template.Levels) { let level = this.run.template.Levels[key]; if (!this.shouldShowLevel(level)) continue; let status = ""; let name = this.getLevelName(level); if (isCompleted(this.run, key)) status = translateWithContext("campaign status", "Completed"); else if (meetsRequirements(this.run, level)) status = coloredText(translateWithContext("campaign status", "Available"), "green"); else name = coloredText(name, "gray"); list.push({ "ID": key, "name": name, "status": status }); } list.sort((a, b) => this.run.template.Order.indexOf(a.ID) - this.run.template.Order.indexOf(b.ID)); list = prepareForDropdown(list); this.levelSelection.list_name = list.name || []; this.levelSelection.list_status = list.status || []; // COList needs these changed last or crashes. this.levelSelection.list = list.ID || []; this.levelSelection.list_data = list.ID || []; } displayLevelDetails() { if (this.selectedLevel === -1) { Engine.GetGUIObjectByName("startButton").enabled = false; Engine.GetGUIObjectByName("startButton").hidden = false; return; } let level = this.getSelectedLevelData(); Engine.GetGUIObjectByName("scenarioName").caption = this.getLevelName(level); Engine.GetGUIObjectByName("scenarioDesc").caption = this.getLevelDescription(level); Engine.GetGUIObjectByName('levelPreviewBox').sprite = this.getLevelPreview(level); Engine.GetGUIObjectByName("startButton").enabled = meetsRequirements(this.run, level); Engine.GetGUIObjectByName("startButton").hidden = false; Engine.GetGUIObjectByName("loadSavedButton").hidden = true; } render() { Engine.GetGUIObjectByName("campaignTitle").caption = this.run.getLabel(); this.displayLevelDetails(); this.displayLevelsList(); } } var g_CampaignMenu; function init(initData) { let run = initData?.filename || CampaignRun.getCurrentRunFilename(); try { run = new CampaignRun(run).load(); if (!run.isCurrent()) run.setCurrent(); g_CampaignMenu = new CampaignMenu(run); } catch (err) { error(sprintf(translate("Error loading campaign run %s: %s."), CampaignRun.getCurrentRunFilename(), err)); Engine.SwitchGuiPage("page_pregame.xml", {}); } } Index: ps/trunk/binaries/data/mods/public/gui/gamesetup/Controllers/GameSettingsController.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/gamesetup/Controllers/GameSettingsController.js (revision 25635) +++ ps/trunk/binaries/data/mods/public/gui/gamesetup/Controllers/GameSettingsController.js (revision 25636) @@ -1,291 +1,304 @@ /** * Controller for the GUI handling of gamesettings. */ class GameSettingsController { constructor(setupWindow, netMessages, playerAssignmentsController, mapCache) { this.setupWindow = setupWindow; this.mapCache = mapCache; this.persistentMatchSettings = new PersistentMatchSettings(g_IsNetworked); this.guiData = new GameSettingsGuiData(); // When joining a game, the complete set of attributes // may not have been received yet. this.loading = true; // If this is true, the ready controller won't reset readiness. // TODO: ideally the ready controller would be somewhat independent from this one, // possibly by listening to gamesetup messages itself. this.gameStarted = false; this.updateLayoutHandlers = new Set(); this.settingsChangeHandlers = new Set(); this.loadingChangeHandlers = new Set(); + this.settingsLoadedHandlers = new Set(); setupWindow.registerLoadHandler(this.onLoad.bind(this)); setupWindow.registerGetHotloadDataHandler(this.onGetHotloadData.bind(this)); setupWindow.registerClosePageHandler(this.onClose.bind(this)); if (g_IsNetworked) { if (g_IsController) playerAssignmentsController.registerClientJoinHandler(this.onClientJoin.bind(this)); else // In MP, the host launches the game and switches right away, // clients switch when they receive the appropriate message. netMessages.registerNetMessageHandler("start", this.switchToLoadingPage.bind(this)); netMessages.registerNetMessageHandler("gamesetup", this.onGamesetupMessage.bind(this)); } } /** * @param handler will be called when the layout needs to be updated. */ registerUpdateLayoutHandler(handler) { this.updateLayoutHandlers.add(handler); } /** * @param handler will be called when any setting change. * (this isn't exactly what happens but the behaviour should be similar). */ registerSettingsChangeHandler(handler) { this.settingsChangeHandlers.add(handler); } /** * @param handler will be called when the 'loading' state change. */ registerLoadingChangeHandler(handler) { this.loadingChangeHandlers.add(handler); } + /** + * @param handler will be called when the initial settings have been loaded. + */ + registerSettingsLoadedHandler(handler) + { + this.settingsLoadedHandlers.add(handler); + } + onLoad(initData, hotloadData) { if (hotloadData) this.parseSettings(hotloadData.initAttributes); - else if (g_IsController && this.persistentMatchSettings.enabled) + else if (g_IsController && (initData?.gameSettings || this.persistentMatchSettings.enabled)) { - let settings = this.persistentMatchSettings.loadFile(); + // Allow opting-in to persistence when sending initial data (though default off) + if (initData?.gameSettings) + this.persistentMatchSettings.enabled = !!initData.gameSettings?.usePersistence; + const settings = initData?.gameSettings || this.persistentMatchSettings.loadFile(); if (settings) - { this.parseSettings(settings); - // If the new settings led to AI & players conflict, remove the AI. - for (let guid in g_PlayerAssignments) - if (g_PlayerAssignments[guid].player !== -1 && - g_GameSettings.playerAI.get(g_PlayerAssignments[guid].player - 1)) - g_GameSettings.playerAI.set(g_PlayerAssignments[guid].player - 1, undefined); - } } + // If the new settings led to AI & players conflict, remove the AI. + for (const guid in g_PlayerAssignments) + if (g_PlayerAssignments[guid].player !== -1 && + g_GameSettings.playerAI.get(g_PlayerAssignments[guid].player - 1)) + g_GameSettings.playerAI.set(g_PlayerAssignments[guid].player - 1, undefined); + + for (const handler of this.settingsLoadedHandlers) + handler(); this.updateLayout(); this.setNetworkInitAttributes(); // If we are the controller, we are done loading. if (hotloadData || !g_IsNetworked || g_IsController) this.setLoading(false); } onClientJoin() { /** * A note on network synchronization: * The net server does not keep the current state of attributes, * nor does it act like a message queue, so a new client * will only receive updates after they've joined. * In particular, new joiners start with no information, * so the controller must first send them a complete copy of the settings. * However, messages could be in-flight towards the controller, * but the new client may never receive these or have already received them, * leading to an ordering issue that might desync the new client. * * The simplest solution is to have the (single) controller * act as the single source of truth. Any other message must * first go through the controller, which will send updates. * This enforces the ordering of the controller. * In practical terms, if e.g. players controlling their own civ is implemented, * the message will need to be ignored by everyone but the controller, * and the controller will need to send an update once it rejects/accepts the changes, * which will then update the other clients. * Of course, the original client GUI may want to temporarily show a different state. * Note that the final attributes are sent on game start anyways, so any * synchronization issue that might happen at that point can be resolved. */ Engine.SendGameSetupMessage({ "type": "initial-update", "initAttribs": this.getSettings() }); } onGetHotloadData(object) { object.initAttributes = this.getSettings(); } onGamesetupMessage(message) { // For now, the controller only can send updates, so no need to listen to messages. if (!message.data || g_IsController) return; if (message.data.type !== "update" && message.data.type !== "initial-update") { error("Unknown message type " + message.data.type); return; } if (message.data.type === "initial-update") { // Ignore initial updates if we've already received settings. if (!this.loading) return; this.setLoading(false); } this.parseSettings(message.data.initAttribs); // This assumes that messages aren't sent spuriously without changes // (which is generally fair), but technically it would be good // to check if the new data is different from the previous data. for (let handler of this.settingsChangeHandlers) handler(); } /** * Returns the InitAttributes, augmented by GUI-specific data. */ getSettings() { let ret = g_GameSettings.toInitAttributes(); ret.guiData = this.guiData.Serialize(); return ret; } /** * Parse the following settings. */ parseSettings(settings) { if (settings.guiData) this.guiData.Deserialize(settings.guiData); g_GameSettings.fromInitAttributes(settings); } setLoading(loading) { if (this.loading === loading) return; this.loading = loading; for (let handler of this.loadingChangeHandlers) handler(loading); } /** * This should be called whenever the GUI layout needs to be updated. * Triggers on the next GUI tick to avoid un-necessary layout. */ updateLayout() { if (this.layoutTimer) return; this.layoutTimer = setTimeout(() => { for (let handler of this.updateLayoutHandlers) handler(); delete this.layoutTimer; }, 0); } /** * 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 game setup message was * received and the data had only been modified deterministically. * * This is run on a timer to avoid flooding the network with messages, * e.g. when modifying a slider. */ setNetworkInitAttributes() { for (let handler of this.settingsChangeHandlers) handler(); if (g_IsNetworked && this.timer === undefined) this.timer = setTimeout(this.setNetworkInitAttributesImmediately.bind(this), this.Timeout); } setNetworkInitAttributesImmediately() { if (this.timer) { clearTimeout(this.timer); delete this.timer; } // See note in onClientJoin on network synchronization. if (g_IsController) Engine.SendGameSetupMessage({ "type": "update", "initAttribs": this.getSettings() }); } /** * Cheat prevention: * * 1. Ensure that the host cannot start the game unless all clients agreed on the game settings using the ready system. * * TODO: * 2. Ensure that the host cannot start the game with InitAttributes different from the agreed ones. * This may be achieved by: * - Determining the seed collectively. * - passing the agreed game settings to the engine when starting the game instance * - rejecting new game settings from the server after the game launch event */ launchGame() { // Save the file before random settings are resolved. this.savePersistentMatchSettings(); // Mark the game as started so the readyController won't reset state. this.gameStarted = true; // This will resolve random settings & send game start messages. // TODO: this will trigger observers, which is somewhat wasteful. g_GameSettings.launchGame(g_PlayerAssignments); // Switch to the loading page right away, // the GUI will otherwise show the unrandomised settings. this.switchToLoadingPage(); } switchToLoadingPage(attributes) { Engine.SwitchGuiPage("page_loading.xml", { "attribs": attributes?.initAttributes || g_GameSettings.toInitAttributes(), "playerAssignments": g_PlayerAssignments }); } onClose() { this.savePersistentMatchSettings(); } savePersistentMatchSettings() { // TODO: ought to only save a subset of settings. this.persistentMatchSettings.saveFile(this.getSettings()); } } /** * Wait (at most) this many milliseconds before sending network messages. */ GameSettingsController.prototype.Timeout = 400; Index: ps/trunk/binaries/data/mods/public/gui/gamesetup/Controllers/GuiData.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/gamesetup/Controllers/GuiData.js (revision 25635) +++ ps/trunk/binaries/data/mods/public/gui/gamesetup/Controllers/GuiData.js (revision 25636) @@ -1,28 +1,35 @@ /** * This class contains network-synchronized data specific to GameSettingsController. * It's split from GameSettingsController for convenience. */ class GameSettingsGuiData { constructor() { this.mapFilter = new Observable(); this.mapFilter.filter = "default"; + + // Mark some settings as unmodifiable even if they normally would be. + // TODO: increase support for this feature. + this.lockSettings = {}; } /** * Serialize for network transmission & settings persistence. */ Serialize() { - let ret = { - "mapFilter": this.mapFilter.filter, + const ret = { + "mapFilter": this.mapFilter.filter }; + if (Object.keys(this.lockSettings).length) + ret.lockSettings = this.lockSettings; return ret; } Deserialize(data) { this.mapFilter.filter = data.mapFilter; + this.lockSettings = data?.lockSettings || {}; } } Index: ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/GameSettingControl.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/GameSettingControl.js (revision 25635) +++ ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/GameSettingControl.js (revision 25636) @@ -1,145 +1,148 @@ /** * The GameSettingControl is an abstract class that is inherited by game-setting control classes specific to a GUI-object type, * such as the GameSettingControlCheckbox or GameSettingControlDropdown. * * The purpose of these classes is to control one logical game setting. * The base classes allow implementing that while avoiding duplication. * * GameSettingControl classes watch for g_GameSettings property changes, * and re-render accordingly. They also trigger changes in g_GameSettings. * * The GameSettingControl classes are responsible for triggering network synchronisation, * and for updating the whole gamesetup layout when necessary. */ class GameSettingControl /* extends Profilable /* Uncomment to profile controls without hassle. */ { constructor(gameSettingControlManager, category, playerIndex, setupWindow) { // Store arguments { this.category = category; this.playerIndex = playerIndex; this.setupWindow = setupWindow; this.gameSettingsController = setupWindow.controls.gameSettingsController; this.mapCache = setupWindow.controls.mapCache; this.mapFilters = setupWindow.controls.mapFilters; this.netMessages = setupWindow.controls.netMessages; this.playerAssignmentsController = setupWindow.controls.playerAssignmentsController; } // enabled and hidden should only be modified through their setters or // by calling updateVisibility after modification. this.enabled = true; this.hidden = false; if (this.setControl) 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.onLoad) this.setupWindow.registerLoadHandler(this.onLoad.bind(this)); + if (this.onSettingsLoaded) + this.gameSettingsController.registerSettingsLoadedHandler(this.onSettingsLoaded.bind(this)); + if (this.onPlayerAssignmentsChange) this.playerAssignmentsController.registerPlayerAssignmentsChangeHandler(this.onPlayerAssignmentsChange.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; if (this.setControlTooltip) this.setControlTooltip(tooltip); } setEnabled(enabled) { this.enabled = enabled; this.updateVisibility(); } setHidden(hidden) { this.hidden = hidden; // Trigger a layout update to reposition items. this.gameSettingsController.updateLayout(); } updateVisibility() { let hidden = this.hidden || this.playerIndex === undefined && this.category != g_TabCategorySelected || this.playerIndex !== undefined && this.playerIndex >= g_GameSettings.playerCount.nbPlayers; if (this.frame) this.frame.hidden = hidden; if (hidden) return; let enabled = g_IsController && this.enabled; if (this.setControlHidden) 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: ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Buttons/MapBrowser.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Buttons/MapBrowser.js (revision 25635) +++ ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Buttons/MapBrowser.js (revision 25636) @@ -1,29 +1,39 @@ GameSettingControls.MapBrowser = class MapBrowser extends GameSettingControlButton { constructor(...args) { super(...args); this.button.tooltip = colorizeHotkey(this.HotkeyTooltip, this.HotkeyConfig); Engine.SetGlobalHotkey(this.HotkeyConfig, "Press", this.onPress.bind(this)); } + onSettingsLoaded() + { + if (this.gameSettingsController.guiData.lockSettings?.map) + { + this.setEnabled(false); + this.setHidden(true); + return; + } + } + setControlHidden() { this.button.hidden = false; } onPress() { this.setupWindow.pages.MapBrowserPage.openPage(); } }; GameSettingControls.MapBrowser.prototype.HotkeyConfig = "gamesetup.mapbrowser.open"; GameSettingControls.MapBrowser.prototype.Caption = translate("Browse Maps"); GameSettingControls.MapBrowser.prototype.HotkeyTooltip = translate("Press %(hotkey)s to view the list of available maps."); Index: ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Dropdowns/MapFilter.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Dropdowns/MapFilter.js (revision 25635) +++ ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Dropdowns/MapFilter.js (revision 25636) @@ -1,68 +1,86 @@ GameSettingControls.MapFilter = class MapFilter extends GameSettingControlDropdown { constructor(...args) { super(...args); this.values = undefined; - this.gameSettingsController.guiData.mapFilter.watch(() => this.render(), ["filter"]); - g_GameSettings.map.watch(() => this.checkMapTypeChange(), ["type"]); + } + + onSettingsLoaded() + { + if (this.gameSettingsController.guiData.lockSettings?.map) + this.setEnabled(false); + else + { + this.gameSettingsController.guiData.mapFilter.watch(() => this.render(), ["filter"]); + g_GameSettings.map.watch(() => this.checkMapTypeChange(), ["type"]); + this.checkMapTypeChange(); + } + this.render(); } onHoverChange() { this.dropdown.tooltip = this.values.Description[this.dropdown.hovered] || this.Tooltip; } checkMapTypeChange() { if (!g_GameSettings.map.type) return; let values = prepareForDropdown( this.mapFilters.getAvailableMapFilters( g_GameSettings.map.type)); if (values.Name.length) { this.dropdown.list = values.Title; this.dropdown.list_data = values.Name; this.values = values; } else this.values = undefined; if (this.values && this.values.Name.indexOf(this.gameSettingsController.guiData.mapFilter.filter) === -1) { this.gameSettingsController.guiData.mapFilter.filter = this.values.Name[this.values.Default]; this.gameSettingsController.setNetworkInitAttributes(); } this.render(); } render() { + if (!this.enabled) + { + if (!this.hidden) + this.setHidden(true); + return; + } + // Index may have changed, reset. this.setSelectedValue(this.gameSettingsController.guiData.mapFilter.filter); this.setHidden(!this.values); } getAutocompleteEntries() { return this.values && this.values.Title; } onSelectionChange(itemIdx) { this.gameSettingsController.guiData.mapFilter.filter = this.values.Name[itemIdx]; } }; GameSettingControls.MapFilter.prototype.TitleCaption = translate("Map Filter"); GameSettingControls.MapFilter.prototype.Tooltip = translate("Select a map filter."); GameSettingControls.MapFilter.prototype.AutocompleteOrder = 0; Index: ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Dropdowns/MapSelection.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Dropdowns/MapSelection.js (revision 25635) +++ ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Dropdowns/MapSelection.js (revision 25636) @@ -1,118 +1,153 @@ GameSettingControls.MapSelection = class MapSelection extends GameSettingControlDropdown { constructor(...args) { super(...args); this.values = undefined; - g_GameSettings.map.watch(() => this.render(), ["map"]); - g_GameSettings.map.watch(() => this.updateMapList(), ["type"]); - - this.gameSettingsController.guiData.mapFilter.watch(() => this.updateMapList(), ["filter"]); - this.randomItem = { "file": this.RandomMapId, "name": setStringTags(this.RandomMapCaption, this.RandomItemTags), "description": this.RandomMapDescription }; } + onSettingsLoaded() + { + if (this.gameSettingsController.guiData.lockSettings?.map) + { + if (!g_GameSettings.map) + { + error("Map setting locked but no map is selected"); + throw new Error(); + } + + this.setTitle(translate("Map")); + this.setEnabled(false); + + // Watch only for map change. + g_GameSettings.map.watch(() => this.render(), ["map"]); + } + else + { + g_GameSettings.map.watch(() => this.render(), ["map"]); + g_GameSettings.map.watch(() => this.updateMapList(), ["type"]); + + this.gameSettingsController.guiData.mapFilter.watch(() => this.updateMapList(), ["filter"]); + + this.updateMapList(); + } + + this.render(); + } + onHoverChange() { this.dropdown.tooltip = this.values.description[this.dropdown.hovered] || this.Tooltip; } render() { - // Can happen with bad matchsettings + if (!this.enabled) + { + const mapData = this.mapCache.getMapData(g_GameSettings.map.mapType, g_GameSettings.map.map); + this.label.caption = g_GameSettings.mapName.value || mapData.settings.Name; + return; + } + + if (!this.values) + return; + + // We can end up with incorrect map selection when dependent settings change. if (this.values.file.indexOf(g_GameSettings.map.map) === -1) { g_GameSettings.map.selectMap(this.values.file[this.values.Default]); return; } + this.setSelectedValue(g_GameSettings.map.map); } updateMapList() { Engine.ProfileStart("updateMapSelectionList"); if (!g_GameSettings.map.type) return; { let values = this.mapFilters.getFilteredMaps( g_GameSettings.map.type, this.gameSettingsController.guiData.mapFilter.filter, false); values.sort(sortNameIgnoreCase); if (g_GameSettings.map.type == "random") values.unshift(this.randomItem); this.values = prepareForDropdown(values); } this.dropdown.list = this.values.name; this.dropdown.list_data = this.values.file; g_GameSettings.map.setRandomOptions(this.values.file); // Reset the selected map. if (this.values.file.indexOf(g_GameSettings.map.map) === -1) { g_GameSettings.map.selectMap(this.values.file[this.values.Default]); this.gameSettingsController.setNetworkInitAttributes(); } // The index may have changed: reset. this.setSelectedValue(g_GameSettings.map.map); Engine.ProfileStop(); } onSelectionChange(itemIdx) { // The triggering that happens on map change can be just slow enough // that the next event happens before we're done when scrolling, // and then the scrolling is not smooth since it can take arbitrarily long to render. // To avoid that, run the change on the next GUI tick, and only do one increment. // TODO: the problem is mostly that updating visibility can relayout the gamesetting, // which takes a few ms, but this could only be done once per frame anyways. // NB: this technically makes it possible to start the game without the change going through // but it's essentially impossible to trigger accidentally. let call = () => { g_GameSettings.map.selectMap(this.values.file[itemIdx]); this.gameSettingsController.setNetworkInitAttributes(); delete this.reRenderTimeout; }; if (this.reRenderTimeout) setNewTimerFunction(this.reRenderTimeout, call); else this.reRenderTimeout = setTimeout(call, 0); } getAutocompleteEntries() { return this.values.name; } }; GameSettingControls.MapSelection.prototype.TitleCaption = translate("Select Map"); GameSettingControls.MapSelection.prototype.Tooltip = translate("Select a map to play on."); GameSettingControls.MapSelection.prototype.RandomMapId = "random"; GameSettingControls.MapSelection.prototype.RandomMapCaption = translateWithContext("map selection", "Random"); GameSettingControls.MapSelection.prototype.RandomMapDescription = translate("Pick any of the given maps at random."); GameSettingControls.MapSelection.prototype.AutocompleteOrder = 0; Index: ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Dropdowns/MapType.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Dropdowns/MapType.js (revision 25635) +++ ps/trunk/binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/GameSettings/Single/Dropdowns/MapType.js (revision 25636) @@ -1,56 +1,70 @@ /** * Maptype design: * Scenario maps have fixed terrain and all settings predetermined. * Skirmish maps have fixed terrain, playercount but settings are free. * For random maps, settings are fully determined by the player and the terrain is generated based on them. */ GameSettingControls.MapType = class MapType extends GameSettingControlDropdown { constructor(...args) { super(...args); this.dropdown.list = g_MapTypes.Title; this.dropdown.list_data = g_MapTypes.Name; - g_GameSettings.map.watch(() => this.render(), ["type"]); - this.render(); } - onLoad() + onSettingsLoaded() { - // Select a default map type if none are currently chosen. - // This in cascade will select a default filter and a default map. - if (!g_GameSettings.map.type) - g_GameSettings.map.setType(g_MapTypes.Name[g_MapTypes.Default]); + if (this.gameSettingsController.guiData.lockSettings?.map) + this.setEnabled(false); + else + { + g_GameSettings.map.watch(() => this.render(), ["type"]); + + // Select a default map type if none are currently chosen. + // This in cascade will select a default filter and a default map. + if (!g_GameSettings.map.type) + g_GameSettings.map.setType(g_MapTypes.Name[g_MapTypes.Default]); + } + + this.render(); } onHoverChange() { this.dropdown.tooltip = g_MapTypes.Description[this.dropdown.hovered] || this.Tooltip; } render() { + if (!this.enabled) + { + if (!this.hidden) + this.setHidden(true); + return; + } + this.setSelectedValue(g_GameSettings.map.type); } getAutocompleteEntries() { return g_MapTypes.Title; } onSelectionChange(itemIdx) { g_GameSettings.map.setType(g_MapTypes.Name[itemIdx]); this.gameSettingsController.setNetworkInitAttributes(); } }; GameSettingControls.MapType.prototype.TitleCaption = translate("Map Type"); GameSettingControls.MapType.prototype.Tooltip = translate("Select a map type."); GameSettingControls.MapType.prototype.AutocompleteOrder = 0;