From d64b95b28c3037c39b6eadb0c6448c7d93dc9ad2 Mon Sep 17 00:00:00 2001 From: Itms Date: Mon, 21 Sep 2015 17:00:21 +0000 Subject: [PATCH] An awesome Visual Replay menu, made by elexis. Fixes #3258. This was SVN commit r17054. --- .../data/mods/public/gui/common/settings.js | 55 +++ .../data/mods/public/gui/page_replaymenu.xml | 15 + .../data/mods/public/gui/pregame/mainmenu.xml | 22 +- .../public/gui/replaymenu/replay_actions.js | 137 +++++++ .../public/gui/replaymenu/replay_filters.js | 220 +++++++++++ .../mods/public/gui/replaymenu/replay_menu.js | 342 ++++++++++++++++++ .../public/gui/replaymenu/replay_menu.xml | 238 ++++++++++++ .../mods/public/gui/replaymenu/styles.xml | 14 + .../data/mods/public/gui/session/session.js | 20 +- .../data/mods/public/gui/summary/summary.xml | 6 +- source/gui/scripting/ScriptFunctions.cpp | 2 + source/ps/Replay.cpp | 21 +- source/ps/Replay.h | 8 + source/ps/VisualReplay.cpp | 305 ++++++++++++++++ source/ps/VisualReplay.h | 78 ++++ 15 files changed, 1467 insertions(+), 16 deletions(-) create mode 100644 binaries/data/mods/public/gui/page_replaymenu.xml create mode 100644 binaries/data/mods/public/gui/replaymenu/replay_actions.js create mode 100644 binaries/data/mods/public/gui/replaymenu/replay_filters.js create mode 100644 binaries/data/mods/public/gui/replaymenu/replay_menu.js create mode 100644 binaries/data/mods/public/gui/replaymenu/replay_menu.xml create mode 100644 binaries/data/mods/public/gui/replaymenu/styles.xml create mode 100644 source/ps/VisualReplay.cpp create mode 100644 source/ps/VisualReplay.h diff --git a/binaries/data/mods/public/gui/common/settings.js b/binaries/data/mods/public/gui/common/settings.js index eea4510eac..a6464b3d64 100644 --- a/binaries/data/mods/public/gui/common/settings.js +++ b/binaries/data/mods/public/gui/common/settings.js @@ -256,3 +256,58 @@ function prepareForDropdown(settingValues) } return settings; } + +/** + * Returns title or placeholder. + * + * @param aiName {string} - for example "petra" + */ +function translateAIName(aiName) +{ + var description = g_Settings.AIDescriptions.find(ai => ai.id == aiName); + return description ? translate(description.data.name) : translate("Unknown"); +} + +/** + * Returns title or placeholder. + * + * @param index {Number} - index of AIDifficulties + */ +function translateAIDifficulty(index) +{ + var difficulty = g_Settings.AIDifficulties[index]; + return difficulty ? difficulty.Title : translate("Unknown"); +} + +/** + * Returns title or placeholder. + * + * @param mapType {string} - for example "skirmish" + */ +function translateMapType(mapType) +{ + var type = g_Settings.MapTypes.find(t => t.Name == mapType); + return type ? type.Title : translate("Unknown"); +} + +/** + * Returns title or placeholder. + * + * @param population {Number} - for example 300 + */ +function translatePopulationCapacity(population) +{ + var popCap = g_Settings.PopulationCapacities.find(p => p.Population == population); + return popCap ? popCap.Title : translate("Unknown"); +} + +/** + * Returns title or placeholder. + * + * @param gameType {string} - for example "conquest" + */ +function translateVictoryCondition(gameType) +{ + var vc = g_Settings.VictoryConditions.find(vc => vc.Name == gameType); + return vc ? vc.Title : translate("Unknown"); +} diff --git a/binaries/data/mods/public/gui/page_replaymenu.xml b/binaries/data/mods/public/gui/page_replaymenu.xml new file mode 100644 index 0000000000..6c38d3dd04 --- /dev/null +++ b/binaries/data/mods/public/gui/page_replaymenu.xml @@ -0,0 +1,15 @@ + + + common/modern/setup.xml + common/modern/styles.xml + common/modern/sprites.xml + + common/setup.xml + common/sprite1.xml + common/styles.xml + common/common_sprites.xml + common/common_styles.xml + + replaymenu/styles.xml + replaymenu/replay_menu.xml + diff --git a/binaries/data/mods/public/gui/pregame/mainmenu.xml b/binaries/data/mods/public/gui/pregame/mainmenu.xml index ca107d5b7b..045e312e38 100644 --- a/binaries/data/mods/public/gui/pregame/mainmenu.xml +++ b/binaries/data/mods/public/gui/pregame/mainmenu.xml @@ -346,10 +346,24 @@ + + Replays + Playback previous games. + + closeMenu(); + Engine.SwitchGuiPage("page_replaymenu.xml"); + + + Scenario Editor @@ -362,7 +376,7 @@ Welcome Screen @@ -377,7 +391,7 @@ Mod Selection @@ -484,7 +498,7 @@ Game options and scenario design tools. closeMenu(); - openMenu("submenuToolsAndOptions", (this.parent.size.top+this.size.top), (this.size.bottom-this.size.top), 5); + openMenu("submenuToolsAndOptions", (this.parent.size.top+this.size.top), (this.size.bottom-this.size.top), 6); diff --git a/binaries/data/mods/public/gui/replaymenu/replay_actions.js b/binaries/data/mods/public/gui/replaymenu/replay_actions.js new file mode 100644 index 0000000000..4ac58114ac --- /dev/null +++ b/binaries/data/mods/public/gui/replaymenu/replay_actions.js @@ -0,0 +1,137 @@ +/** + * Starts the selected visual replay, or shows an error message in case of incompatibility. + */ +function startReplay() +{ + var selected = Engine.GetGUIObjectByName("replaySelection").selected; + if (selected == -1) + return; + + var replay = g_ReplaysFiltered[selected]; + if (isReplayCompatible(replay)) + reallyStartVisualReplay(replay.directory); + else + displayReplayCompatibilityError(replay); +} + +/** + * Attempts the visual replay, regardless of the compatibility. + * + * @param replayDirectory {string} + */ +function reallyStartVisualReplay(replayDirectory) +{ + // TODO: enhancement: restore filter settings and selected replay when returning from the summary screen. + Engine.StartVisualReplay(replayDirectory); + Engine.SwitchGuiPage("page_loading.xml", { + "attribs": Engine.GetReplayAttributes(replayDirectory), + "isNetworked" : false, + "playerAssignments": {}, + "savedGUIData": "", + "isReplay" : true + }); +} + +/** + * Shows an error message stating why the replay is not compatible. + * + * @param replay {Object} + */ +function displayReplayCompatibilityError(replay) +{ + var errMsg; + if (replayHasSameEngineVersion(replay)) + { + let gameMods = replay.attribs.mods ? replay.attribs.mods : []; + errMsg = translate("You don't have the same mods active as the replay does.") + "\n"; + errMsg += sprintf(translate("Required: %(mods)s"), { "mods": gameMods.join(", ") }) + "\n"; + errMsg += sprintf(translate("Active: %(mods)s"), { "mods": g_EngineInfo.mods.join(", ") }); + } + else + errMsg = translate("This replay is not compatible with your version of the game!"); + + messageBox(500, 200, errMsg, translate("Incompatible replay"), 0, [translate("Ok")], [null]); +} + +/** + * Opens the summary screen of the given replay, if its data was found in that directory. + */ +function showReplaySummary() +{ + var selected = Engine.GetGUIObjectByName("replaySelection").selected; + if (selected == -1) + return; + + // Load summary screen data from the selected replay directory + var summary = Engine.GetReplayMetadata(g_ReplaysFiltered[selected].directory); + + if (!summary) + { + messageBox(500, 200, translate("No summary data available."), translate("Error"), 0, [translate("Ok")], [null]); + return; + } + + // Open summary screen + summary.isReplay = true; + summary.gameResult = translate("Scores at the end of the game."); + Engine.SwitchGuiPage("page_summary.xml", summary); +} + +/** + * Callback. + */ +function deleteReplayButtonPressed() +{ + if (!Engine.GetGUIObjectByName("deleteReplayButton").enabled) + return; + + if (Engine.HotkeyIsPressed("session.savedgames.noConfirmation")) + deleteReplayWithoutConfirmation(); + else + deleteReplay(); +} +/** + * Shows a confirmation dialog and deletes the selected replay from the disk in case. + */ +function deleteReplay() +{ + // Get selected replay + var selected = Engine.GetGUIObjectByName("replaySelection").selected; + if (selected == -1) + return; + + var replay = g_ReplaysFiltered[selected]; + + // Show confirmation message + var btCaptions = [translate("Yes"), translate("No")]; + var btCode = [function() { reallyDeleteReplay(replay.directory); }, null]; + + var title = translate("Delete replay"); + var question = translate("Are you sure to delete this replay permanently?") + "\n" + escapeText(replay.file); + + messageBox(500, 200, question, title, 0, btCaptions, btCode); +} + +/** + * Attempts to delete the selected replay from the disk. + */ +function deleteReplayWithoutConfirmation() +{ + var selected = Engine.GetGUIObjectByName("replaySelection").selected; + if (selected > -1) + reallyDeleteReplay(g_ReplaysFiltered[selected].directory); +} + +/** + * Attempts to delete the given replay directory from the disk. + * + * @param replayDirectory {string} + */ +function reallyDeleteReplay(replayDirectory) +{ + if (!Engine.DeleteReplay(replayDirectory)) + error(sprintf("Could not delete replay '%(id)s'", { "id": replayDirectory })); + + // Refresh replay list + init(); +} diff --git a/binaries/data/mods/public/gui/replaymenu/replay_filters.js b/binaries/data/mods/public/gui/replaymenu/replay_filters.js new file mode 100644 index 0000000000..6446dec94b --- /dev/null +++ b/binaries/data/mods/public/gui/replaymenu/replay_filters.js @@ -0,0 +1,220 @@ +/** + * Allow to filter replays by duration in 15min / 30min intervals. + */ +const g_DurationFilterIntervals = [ + { "min": -1, "max": -1 }, + { "min": -1, "max": 15 }, + { "min": 15, "max": 30 }, + { "min": 30, "max": 45 }, + { "min": 45, "max": 60 }, + { "min": 60, "max": 90 }, + { "min": 90, "max": 120 }, + { "min": 120, "max": -1 } +]; + +/** + * Allow to filter by population capacity. + */ +const g_PopulationCapacities = prepareForDropdown(g_Settings ? g_Settings.PopulationCapacities : undefined); + +/** + * Reloads the selectable values in the filters. The filters depend on g_Settings and g_Replays + * (including its derivatives g_MapSizes, g_MapNames). + */ +function initFilters() +{ + initDateFilter(); + initMapNameFilter(); + initMapSizeFilter(); + initPopCapFilter(); + initDurationFilter(); +} + +/** + * Allow to filter by month. Uses g_Replays. + */ +function initDateFilter() +{ + var months = g_Replays.map(replay => getReplayMonth(replay)); + months = months.filter((month, index) => months.indexOf(month) == index).sort(); + months.unshift(translateWithContext("datetime", "Any")); + + var dateTimeFilter = Engine.GetGUIObjectByName("dateTimeFilter"); + dateTimeFilter.list = months; + dateTimeFilter.list_data = months; + + if (dateTimeFilter.selected == -1 || dateTimeFilter.selected >= dateTimeFilter.list.length) + dateTimeFilter.selected = 0; +} + +/** + * Allow to filter by mapsize. Uses g_MapSizes. + */ +function initMapSizeFilter() +{ + var mapSizeFilter = Engine.GetGUIObjectByName("mapSizeFilter"); + mapSizeFilter.list = [translateWithContext("map size", "Any")].concat(g_mapSizes.shortNames); + mapSizeFilter.list_data = [-1].concat(g_mapSizes.tiles); + + if (mapSizeFilter.selected == -1 || mapSizeFilter.selected >= mapSizeFilter.list.length) + mapSizeFilter.selected = 0; +} + +/** + * Allow to filter by mapname. Uses g_MapNames. + */ +function initMapNameFilter() +{ + var mapNameFilter = Engine.GetGUIObjectByName("mapNameFilter"); + mapNameFilter.list = [translateWithContext("map name", "Any")].concat(g_MapNames); + mapNameFilter.list_data = [""].concat(g_MapNames.map(mapName => translate(mapName))); + + if (mapNameFilter.selected == -1 || mapNameFilter.selected >= mapNameFilter.list.length) + mapNameFilter.selected = 0; +} + +/** + * Allow to filter by population capacity. + */ +function initPopCapFilter() +{ + var populationFilter = Engine.GetGUIObjectByName("populationFilter"); + populationFilter.list = [translateWithContext("population capacity", "Any")].concat(g_PopulationCapacities.Title); + populationFilter.list_data = [""].concat(g_PopulationCapacities.Population); + + if (populationFilter.selected == -1 || populationFilter.selected >= populationFilter.list.length) + populationFilter.selected = 0; +} + +/** + * Allow to filter by game duration. Uses g_DurationFilterIntervals. + */ +function initDurationFilter() +{ + var durationFilter = Engine.GetGUIObjectByName("durationFilter"); + durationFilter.list = g_DurationFilterIntervals.map((interval, index) => { + + if (index == 0) + return translateWithContext("duration", "Any"); + + if (index == 1) + // Translation: Shorter duration than max minutes. + return sprintf(translateWithContext("duration filter", "< %(max)s min"), interval); + + if (index == g_DurationFilterIntervals.length - 1) + // Translation: Longer duration than min minutes. + return sprintf(translateWithContext("duration filter", "> %(min)s min"), interval); + + // Translation: Duration between min and max minutes. + return sprintf(translateWithContext("duration filter", "%(min)s - %(max)s min"), interval); + }); + durationFilter.list_data = g_DurationFilterIntervals.map((interval, index) => index); + + if (durationFilter.selected == -1 || durationFilter.selected >= g_DurationFilterIntervals.length) + durationFilter.selected = 0; +} + +/** + * Initializes g_ReplaysFiltered with replays that are not filtered out and sort it. + */ +function filterReplays() +{ + const sortKey = Engine.GetGUIObjectByName("replaySelection").selected_column; + const sortOrder = Engine.GetGUIObjectByName("replaySelection").selected_column_order; + + g_ReplaysFiltered = g_Replays.filter(replay => filterReplay(replay)).sort((a, b) => + { + let cmpA, cmpB; + switch (sortKey) + { + case 'name': + cmpA = +a.timestamp; + cmpB = +b.timestamp; + break; + case 'duration': + cmpA = +a.duration; + cmpB = +b.duration; + break; + case 'players': + cmpA = +a.attribs.settings.PlayerData.length; + cmpB = +b.attribs.settings.PlayerData.length; + break; + case 'mapName': + cmpA = getReplayMapName(a); + cmpB = getReplayMapName(b); + break; + case 'mapSize': + cmpA = +a.attribs.settings.Size; + cmpB = +b.attribs.settings.Size; + break; + case 'popCapacity': + cmpA = +a.attribs.settings.PopulationCap; + cmpB = +b.attribs.settings.PopulationCap; + break; + } + + if (cmpA < cmpB) + return -sortOrder; + else if (cmpA > cmpB) + return +sortOrder; + + return 0; + }); +} + +/** + * Decides whether the replay should be listed. + * + * @returns {bool} - true if replay should be visible + */ +function filterReplay(replay) +{ + // Check for compability first (most likely to filter) + var compabilityFilter = Engine.GetGUIObjectByName("compabilityFilter"); + if (compabilityFilter.checked && !isReplayCompatible(replay)) + return false; + + // Filter date/time (select a month) + var dateTimeFilter = Engine.GetGUIObjectByName("dateTimeFilter"); + if (dateTimeFilter.selected > 0 && getReplayMonth(replay) != dateTimeFilter.list_data[dateTimeFilter.selected]) + return false; + + // Filter by playernames + var playersFilter = Engine.GetGUIObjectByName("playersFilter"); + var keywords = playersFilter.caption.toLowerCase().split(" "); + if (keywords.length) + { + // We just check if all typed words are somewhere in the playerlist of that replay + let playerList = replay.attribs.settings.PlayerData.map(player => player ? player.Name : "").join(" ").toLowerCase(); + if (!keywords.every(keyword => playerList.indexOf(keyword) != -1)) + return false; + } + + // Filter by map name + var mapNameFilter = Engine.GetGUIObjectByName("mapNameFilter"); + if (mapNameFilter.selected > 0 && getReplayMapName(replay) != mapNameFilter.list_data[mapNameFilter.selected]) + return false; + + // Filter by map size + var mapSizeFilter = Engine.GetGUIObjectByName("mapSizeFilter"); + if (mapSizeFilter.selected > 0 && replay.attribs.settings.Size != mapSizeFilter.list_data[mapSizeFilter.selected]) + return false; + + // Filter by population capacity + var populationFilter = Engine.GetGUIObjectByName("populationFilter"); + if (populationFilter.selected > 0 && replay.attribs.settings.PopulationCap != populationFilter.list_data[populationFilter.selected]) + return false; + + // Filter by game duration + var durationFilter = Engine.GetGUIObjectByName("durationFilter"); + if (durationFilter.selected > 0) + { + let interval = g_DurationFilterIntervals[durationFilter.selected]; + + if ((interval.min > -1 && replay.duration < interval.min * 60) || + (interval.max > -1 && replay.duration > interval.max * 60)) + return false; + } + + return true; +} diff --git a/binaries/data/mods/public/gui/replaymenu/replay_menu.js b/binaries/data/mods/public/gui/replaymenu/replay_menu.js new file mode 100644 index 0000000000..9e3172f343 --- /dev/null +++ b/binaries/data/mods/public/gui/replaymenu/replay_menu.js @@ -0,0 +1,342 @@ +const g_EngineInfo = Engine.GetEngineInfo(); +const g_CivData = loadCivData(); +const g_DefaultPlayerData = initPlayerDefaults(); +const g_mapSizes = initMapSizes(); + +/** + * All replays found in the directory. + */ +var g_Replays = []; + +/** + * List of replays after applying the display filter. + */ +var g_ReplaysFiltered = []; + +/** + * Array of unique usernames of all replays. Used for autocompleting usernames. + */ +var g_Playernames = []; + +/** + * Sorted list of unique maptitles. Used by mapfilter. + */ +var g_MapNames = []; + +/** + * Directory name of the currently selected replay. Used to restore the selection after changing filters. + */ +var g_selectedReplayDirectory = ""; + +/** + * Initializes globals, loads replays and displays the list. + */ +function init() +{ + if (!g_Settings) + { + Engine.SwitchGuiPage("page_pregame.xml"); + return; + } + + // By default, sort replays by date in descending order + Engine.GetGUIObjectByName("replaySelection").selected_column_order = -1; + + loadReplays(); + displayReplayList(); +} + +/** + * Store the list of replays loaded in C++ in g_Replays. + * Check timestamp and compatibility and extract g_Playernames, g_MapNames + */ +function loadReplays() +{ + g_Replays = Engine.GetReplays(); + + g_Playernames = []; + for (let replay of g_Replays) + { + // Use time saved in file, otherwise file mod date + replay.timestamp = replay.attribs.timestamp ? +replay.attribs.timestamp : +replay.filemod_timestamp; + + // Check replay for compability + replay.isCompatible = isReplayCompatible(replay); + + sanitizeGameAttributes(replay.attribs); + + // Extract map names + if (g_MapNames.indexOf(replay.attribs.settings.Name) == -1 && replay.attribs.settings.Name != "") + g_MapNames.push(replay.attribs.settings.Name); + + // Extract playernames + for (let playerData of replay.attribs.settings.PlayerData) + { + if (!playerData || playerData.AI) + continue; + + // Remove rating from nick + let playername = playerData.Name; + let ratingStart = playername.indexOf(" ("); + if (ratingStart != -1) + playername = playername.substr(0, ratingStart); + + if (g_Playernames.indexOf(playername) == -1) + g_Playernames.push(playername); + } + } + g_MapNames.sort(); + + // Reload filters (since they depend on g_Replays and its derivatives) + initFilters(); +} + +/** + * We may encounter malformed replays. + */ +function sanitizeGameAttributes(attribs) +{ + if (!attribs.settings) + attribs.settings = {}; + + if (!attribs.settings.Size) + attribs.settings.Size = -1; + + if (!attribs.settings.Name) + attribs.settings.Name = ""; + + if (!attribs.settings.PlayerData) + attribs.settings.PlayerData = []; + + if (!attribs.settings.PopulationCap) + attribs.settings.PopulationCap = 300; + + if (!attribs.settings.mapType) + attribs.settings.mapType = "skirmish"; + + if (!attribs.settings.GameType) + attribs.settings.GameType = "conquest"; + + // Remove gaia + if (attribs.settings.PlayerData.length && attribs.settings.PlayerData[0] == null) + attribs.settings.PlayerData.shift(); + + attribs.settings.PlayerData.forEach((pData, index) => { + if (!pData.Name) + pData.Name = ""; + }); +} + +/** + * Filter g_Replays, fill the GUI list with that data and show the description of the current replay. + */ +function displayReplayList() +{ + // Remember previously selected replay + var replaySelection = Engine.GetGUIObjectByName("replaySelection"); + if (replaySelection.selected != -1) + g_selectedReplayDirectory = g_ReplaysFiltered[replaySelection.selected].directory; + + filterReplays(); + + // Create GUI list data + var list = g_ReplaysFiltered.map(replay => { + let works = replay.isCompatible; + return { + "directories": replay.directory, + "months": greyout(getReplayDateTime(replay), works), + "popCaps": greyout(translatePopulationCapacity(replay.attribs.settings.PopulationCap), works), + "mapNames": greyout(getReplayMapName(replay), works), + "mapSizes": greyout(translateMapSize(replay.attribs.settings.Size), works), + "durations": greyout(getReplayDuration(replay), works), + "playerNames": greyout(getReplayPlayernames(replay), works) + }; + }); + + // Extract arrays + if (list.length) + list = prepareForDropdown(list); + + // Push to GUI + replaySelection.selected = -1; + replaySelection.list_name = list.months || []; + replaySelection.list_players = list.playerNames || []; + replaySelection.list_mapName = list.mapNames || []; + replaySelection.list_mapSize = list.mapSizes || []; + replaySelection.list_popCapacity = list.popCaps || []; + replaySelection.list_duration = list.durations || []; + + // Change these last, otherwise crash + replaySelection.list = list.directories || []; + replaySelection.list_data = list.directories || []; + + // Restore selection + replaySelection.selected = replaySelection.list.findIndex(directory => directory == g_selectedReplayDirectory); + + displayReplayDetails(); +} + +/** + * Shows preview image, description and player text in the right panel. + */ +function displayReplayDetails() +{ + var selected = Engine.GetGUIObjectByName("replaySelection").selected; + var replaySelected = selected > -1; + + Engine.GetGUIObjectByName("replayInfo").hidden = !replaySelected; + Engine.GetGUIObjectByName("replayInfoEmpty").hidden = replaySelected; + Engine.GetGUIObjectByName("startReplayButton").enabled = replaySelected; + Engine.GetGUIObjectByName("deleteReplayButton").enabled = replaySelected; + Engine.GetGUIObjectByName("summaryButton").enabled = replaySelected; + + if (!replaySelected) + return; + + var replay = g_ReplaysFiltered[selected]; + var mapData = getMapDescriptionAndPreview(replay.attribs.settings.mapType, replay.attribs.map); + + // Update GUI + Engine.GetGUIObjectByName("sgMapName").caption = translate(replay.attribs.settings.Name); + Engine.GetGUIObjectByName("sgMapSize").caption = translateMapSize(replay.attribs.settings.Size); + Engine.GetGUIObjectByName("sgMapType").caption = translateMapType(replay.attribs.settings.mapType); + Engine.GetGUIObjectByName("sgVictory").caption = translateVictoryCondition(replay.attribs.settings.GameType); + Engine.GetGUIObjectByName("sgNbPlayers").caption = replay.attribs.settings.PlayerData.length; + Engine.GetGUIObjectByName("sgPlayersNames").caption = getReplayTeamText(replay); + Engine.GetGUIObjectByName("sgMapDescription").caption = mapData.description; + Engine.GetGUIObjectByName("sgMapPreview").sprite = "cropped:(0.7812,0.5859)session/icons/mappreview/" + mapData.preview; +} + +/** + * Adds grey font if replay is not compatible. + */ +function greyout(text, isCompatible) +{ + return isCompatible ? text : '[color="96 96 96"]' + text + '[/color]'; +} + +/** + * Returns a human-readable version of the replay date. + */ +function getReplayDateTime(replay) +{ + return Engine.FormatMillisecondsIntoDateString(replay.timestamp * 1000, translate("yyyy-MM-dd HH:mm")) +} + +/** + * Returns a human-readable list of the playernames of that replay. + * + * @returns {string} + */ +function getReplayPlayernames(replay) +{ + // TODO: colorize playernames like in the lobby. + return replay.attribs.settings.PlayerData.map(pData => pData.Name).join(", "); +} + +/** + * Returns the name of the map of the given replay. + * + * @returns {string} + */ +function getReplayMapName(replay) +{ + return translate(replay.attribs.settings.Name); +} + +/** + * Returns the month of the given replay in the format "yyyy-MM". + * + * @returns {string} + */ +function getReplayMonth(replay) +{ + return Engine.FormatMillisecondsIntoDateString(replay.timestamp * 1000, translate("yyyy-MM")); +} + +/** + * Returns a human-readable version of the time when the replay started. + * + * @returns {string} + */ +function getReplayDuration(replay) +{ + return timeToString(replay.duration * 1000); +} + +/** + * True if we can start the given replay with the currently loaded mods. + */ +function isReplayCompatible(replay) +{ + return replayHasSameEngineVersion(replay) && hasSameMods(replay.attribs, g_EngineInfo); +} + +/** + * True if we can start the given replay with the currently loaded mods. + */ +function replayHasSameEngineVersion(replay) +{ + return replay.attribs.engine_version && replay.attribs.engine_version == g_EngineInfo.engine_version; +} + +/** + * Returns a description of the player assignments. + * Including civs, teams, AI settings and player colors. + * + * If the spoiler-checkbox is checked, it also shows defeated players. + * + * @returns {string} + */ +function getReplayTeamText(replay) +{ + // Load replay metadata + const metadata = Engine.GetReplayMetadata(replay.directory); + const spoiler = Engine.GetGUIObjectByName("showSpoiler").checked; + + var playerDescriptions = {}; + var playerIdx = 0; + for (let playerData of replay.attribs.settings.PlayerData) + { + // Get player info + ++playerIdx; + let teamIdx = playerData.Team; + let playerColor = playerData.Color ? playerData.Color : g_DefaultPlayerData[playerIdx].Color; + let showDefeated = spoiler && metadata && metadata.playerStates && metadata.playerStates[playerIdx].state == "defeated"; + let isAI = playerData.AI; + + // Create human-readable player description + let playerDetails = { + "playerName": '[color="' + rgbToGuiColor(playerColor) + '"]' + escapeText(playerData.Name) + "[/color]", + "civ": translate(g_CivData[playerData.Civ].Name), + "AIname": isAI ? translateAIName(playerData.AI) : "", + "AIdifficulty": isAI ? translateAIDifficulty(playerData.AIDiff) : "" + }; + + if (!isAI && !showDefeated) + playerDetails = sprintf(translateWithContext("replay", "%(playerName)s (%(civ)s)"), playerDetails); + else if (!isAI && showDefeated) + playerDetails = sprintf(translateWithContext("replay", "%(playerName)s (%(civ)s, defeated)"), playerDetails); + else if (isAI && !showDefeated) + playerDetails = sprintf(translateWithContext("replay", "%(playerName)s (%(civ)s, %(AIdifficulty)s %(AIname)s)"), playerDetails); + else + playerDetails = sprintf(translateWithContext("replay", "%(playerName)s (%(civ)s, %(AIdifficulty)s %(AIname)s, defeated)"), playerDetails); + + // Sort player descriptions by team + if (!playerDescriptions[teamIdx]) + playerDescriptions[teamIdx] = []; + playerDescriptions[teamIdx].push(playerDetails); + } + + var teams = Object.keys(playerDescriptions); + + // If there are no teams, merge all playersDescriptions + if (teams.length == 1) + return playerDescriptions[teams[0]].join("\n") + "\n"; + + // If there are teams, merge "Team N:" + playerDescriptions + return teams.map(team => { + let teamCaption = (team == -1) ? translate("No Team") : sprintf(translate("Team %(team)s"), { "team": +team + 1 }); + return '[font="sans-bold-14"]' + teamCaption + "[/font]:\n" + playerDescriptions[team].join("\n"); + }).join("\n"); +} diff --git a/binaries/data/mods/public/gui/replaymenu/replay_menu.xml b/binaries/data/mods/public/gui/replaymenu/replay_menu.xml new file mode 100644 index 0000000000..1b752eefd5 --- /dev/null +++ b/binaries/data/mods/public/gui/replaymenu/replay_menu.xml @@ -0,0 +1,238 @@ + + + + + +