forked from 0ad/0ad
An awesome Visual Replay menu, made by elexis. Fixes #3258.
This was SVN commit r17054.
This commit is contained in:
parent
7002af5b9c
commit
d64b95b28c
@ -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");
|
||||
}
|
||||
|
15
binaries/data/mods/public/gui/page_replaymenu.xml
Normal file
15
binaries/data/mods/public/gui/page_replaymenu.xml
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<page>
|
||||
<include>common/modern/setup.xml</include>
|
||||
<include>common/modern/styles.xml</include>
|
||||
<include>common/modern/sprites.xml</include>
|
||||
|
||||
<include>common/setup.xml</include>
|
||||
<include>common/sprite1.xml</include>
|
||||
<include>common/styles.xml</include>
|
||||
<include>common/common_sprites.xml</include>
|
||||
<include>common/common_styles.xml</include>
|
||||
|
||||
<include>replaymenu/styles.xml</include>
|
||||
<include>replaymenu/replay_menu.xml</include>
|
||||
</page>
|
@ -346,10 +346,24 @@
|
||||
</action>
|
||||
</object>
|
||||
|
||||
<object name="submenuReplayButton"
|
||||
type="button"
|
||||
style="StoneButtonFancy"
|
||||
size="0 64 100% 92"
|
||||
tooltip_style="pgToolTip"
|
||||
>
|
||||
<translatableAttribute id="caption">Replays</translatableAttribute>
|
||||
<translatableAttribute id="tooltip">Playback previous games.</translatableAttribute>
|
||||
<action on="Press">
|
||||
closeMenu();
|
||||
Engine.SwitchGuiPage("page_replaymenu.xml");
|
||||
</action>
|
||||
</object>
|
||||
|
||||
<object name="submenuEditorButton"
|
||||
style="StoneButtonFancy"
|
||||
type="button"
|
||||
size="0 64 100% 92"
|
||||
size="0 96 100% 124"
|
||||
tooltip_style="pgToolTip"
|
||||
>
|
||||
<translatableAttribute id="caption">Scenario Editor</translatableAttribute>
|
||||
@ -362,7 +376,7 @@
|
||||
<object name="submenuWelcomeScreenButton"
|
||||
style="StoneButtonFancy"
|
||||
type="button"
|
||||
size="0 96 100% 124"
|
||||
size="0 128 100% 156"
|
||||
tooltip_style="pgToolTip"
|
||||
>
|
||||
<translatableAttribute id="caption">Welcome Screen</translatableAttribute>
|
||||
@ -377,7 +391,7 @@
|
||||
<object name="submenuModSelection"
|
||||
style="StoneButtonFancy"
|
||||
type="button"
|
||||
size="0 128 100% 156"
|
||||
size="0 156 100% 188"
|
||||
tooltip_style="pgToolTip"
|
||||
>
|
||||
<translatableAttribute id="caption">Mod Selection</translatableAttribute>
|
||||
@ -484,7 +498,7 @@
|
||||
<translatableAttribute id="tooltip">Game options and scenario design tools.</translatableAttribute>
|
||||
<action on="Press">
|
||||
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);
|
||||
</action>
|
||||
</object>
|
||||
|
||||
|
137
binaries/data/mods/public/gui/replaymenu/replay_actions.js
Normal file
137
binaries/data/mods/public/gui/replaymenu/replay_actions.js
Normal file
@ -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();
|
||||
}
|
220
binaries/data/mods/public/gui/replaymenu/replay_filters.js
Normal file
220
binaries/data/mods/public/gui/replaymenu/replay_filters.js
Normal file
@ -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;
|
||||
}
|
342
binaries/data/mods/public/gui/replaymenu/replay_menu.js
Normal file
342
binaries/data/mods/public/gui/replaymenu/replay_menu.js
Normal file
@ -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");
|
||||
}
|
238
binaries/data/mods/public/gui/replaymenu/replay_menu.xml
Normal file
238
binaries/data/mods/public/gui/replaymenu/replay_menu.xml
Normal file
@ -0,0 +1,238 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<objects>
|
||||
|
||||
<!-- Used to display game info. -->
|
||||
<script file="gui/common/functions_civinfo.js" />
|
||||
<script file="gui/common/functions_utility.js" />
|
||||
<script file="gui/common/settings.js" />
|
||||
|
||||
<!-- Used to display message boxes. -->
|
||||
<script file="gui/common/functions_global_object.js" />
|
||||
|
||||
<!-- Used for engine + mod version checks. -->
|
||||
<script file="gui/common/functions_utility_loadsave.js" />
|
||||
|
||||
<!-- Actual replay scripts after settings.js, as it initializes g_Settings. -->
|
||||
<script file="gui/replaymenu/replay_menu.js" />
|
||||
<script file="gui/replaymenu/replay_actions.js" />
|
||||
<script file="gui/replaymenu/replay_filters.js" />
|
||||
|
||||
<!-- Everything displayed in the replay menu. -->
|
||||
<object type="image" style="ModernWindow" size="0 0 100% 100%" name="replayWindow">
|
||||
|
||||
<!-- Title -->
|
||||
<object style="ModernLabelText" type="text" size="50%-128 4 50%+128 36">
|
||||
<translatableAttribute id="caption">Replay Games</translatableAttribute>
|
||||
</object>
|
||||
|
||||
<!-- Left Panel: Filters & Replay List -->
|
||||
<object name="leftPanel" size="3% 5% 100%-255 100%-80">
|
||||
|
||||
<!-- Filters -->
|
||||
<object name="filterPanel" size="0 0 100% 24">
|
||||
|
||||
<object name="dateTimeFilter" type="dropdown" style="ModernDropDown" size="5 0 12%-10 100%" font="sans-bold-13">
|
||||
<action on="SelectionChange">displayReplayList();</action>
|
||||
</object>
|
||||
|
||||
<object name="playersFilter" type="input" style="ModernInput" size="12%-5 0 56%-10 100%" font="sans-bold-13">
|
||||
<action on="Press">displayReplayList();</action>
|
||||
<action on="Tab">autoCompleteNick("playersFilter", g_Playernames.map(name => ({ "name": name })));</action>
|
||||
</object>
|
||||
|
||||
<object name="mapNameFilter" type="dropdown" style="ModernDropDown" size="56%-5 0 70%-10 100%" font="sans-bold-13">
|
||||
<action on="SelectionChange">displayReplayList();</action>
|
||||
</object>
|
||||
|
||||
<object name="mapSizeFilter" type="dropdown" style="ModernDropDown" size="70%-5 0 80%-10 100%" font="sans-bold-13">
|
||||
<action on="SelectionChange">displayReplayList();</action>
|
||||
</object>
|
||||
|
||||
<object name="populationFilter" type="dropdown" style="ModernDropDown" size="80%-5 0 90%-10 100%" font="sans-bold-13">
|
||||
<action on="SelectionChange">displayReplayList();</action>
|
||||
</object>
|
||||
|
||||
<object name="durationFilter" type="dropdown" style="ModernDropDown" size="90%-5 0 100%-10 100%" font="sans-bold-13">
|
||||
<action on="SelectionChange">displayReplayList();</action>
|
||||
</object>
|
||||
|
||||
</object>
|
||||
|
||||
<!-- Replay List in that left panel -->
|
||||
<object name="replaySelection" size="0 35 100% 100%" style="ModernList" type="olist" sortable="true" default_column="name" sprite_asc="ModernArrowDown" sprite_desc="ModernArrowUp" sprite_not_sorted="ModernNotSorted" font="sans-stroke-13">
|
||||
|
||||
<action on="SelectionChange">displayReplayDetails();</action>
|
||||
<action on="SelectionColumnChange">displayReplayList();</action>
|
||||
|
||||
<!-- Columns -->
|
||||
<!-- We have to call one "name" as the GUI expects one. -->
|
||||
<def id="name" color="172 172 212" width="12%">
|
||||
<translatableAttribute id="heading" context="replay">Date / Time</translatableAttribute>
|
||||
</def>
|
||||
|
||||
<def id="players" color="192 192 192" width="44%">
|
||||
<translatableAttribute id="heading" context="replay">Players</translatableAttribute>
|
||||
</def>
|
||||
|
||||
<def id="mapName" color="192 192 192" width="14%">
|
||||
<translatableAttribute id="heading" context="replay">Map Name</translatableAttribute>
|
||||
</def>
|
||||
|
||||
<def id="mapSize" color="192 192 192" width="10%">
|
||||
<translatableAttribute id="heading" context="replay">Size</translatableAttribute>
|
||||
</def>
|
||||
|
||||
<def id="popCapacity" color="192 192 192" width="10%">
|
||||
<translatableAttribute id="heading" context="replay">Population</translatableAttribute>
|
||||
</def>
|
||||
|
||||
<def id="duration" color="192 192 192" width="10%">
|
||||
<translatableAttribute id="heading" context="replay">Duration</translatableAttribute>
|
||||
</def>
|
||||
|
||||
</object>
|
||||
|
||||
</object>
|
||||
|
||||
<!-- Right Panel: Compability Filter & Replay Details -->
|
||||
<object name="rightPanel" size="100%-250 30 100%-20 100%-20" >
|
||||
|
||||
<!-- Compability Filter Checkbox -->
|
||||
<object name="compabilityFilter" type="checkbox" checked="true" style="ModernTickBox" size="0 4 20 100%" font="sans-bold-13">
|
||||
<action on="Press">displayReplayList();</action>
|
||||
</object>
|
||||
|
||||
<!-- Compability Filter Label -->
|
||||
<object type="text" size="20 2 100% 100%" text_align="left" textcolor="white">
|
||||
<translatableAttribute id="caption">Filter compatible replays</translatableAttribute>
|
||||
</object>
|
||||
|
||||
<!-- Placeholder to show if no replay is selected -->
|
||||
<object name="replayInfoEmpty" size="0 30 100% 100%-60" type="image" sprite="ModernDarkBoxGold" hidden="false">
|
||||
<object name="logo" size="50%-110 40 50%+110 140" type="image" sprite="logo"/>
|
||||
<object name="subjectBox" type="image" sprite="ModernDarkBoxWhite" size="3% 180 97% 99%">
|
||||
<object name="subject" size="5 5 100%-5 100%-5" type="text" style="ModernText" text_align="center"/>
|
||||
</object>
|
||||
</object>
|
||||
|
||||
<!-- -->
|
||||
<object name="replayInfo" size="0 30 100% 100%-60" type="image" sprite="ModernDarkBoxGold" hidden="true">
|
||||
|
||||
<!-- Map Name Label -->
|
||||
<object name="sgMapName" size="0 5 100% 20" type="text" style="ModernLabelText"/>
|
||||
|
||||
<!-- Map Preview Image -->
|
||||
<object name="sgMapPreview" size="5 25 100%-5 190" type="image" sprite=""/>
|
||||
|
||||
<!-- Separator Line -->
|
||||
<object size="5 194 100%-5 195" type="image" sprite="ModernWhiteLine" z="25"/>
|
||||
|
||||
<!-- Map Type Caption -->
|
||||
<object size="5 195 50% 225" type="image" sprite="ModernItemBackShadeLeft">
|
||||
<object size="0 0 100%-10 100%" type="text" style="ModernLabelText" text_align="right">
|
||||
<translatableAttribute id="caption">Map Type:</translatableAttribute>
|
||||
</object>
|
||||
</object>
|
||||
|
||||
<!-- Map Type Label -->
|
||||
<object size="50% 195 100%-5 225" type="image" sprite="ModernItemBackShadeRight">
|
||||
<object name="sgMapType" size="0 0 100% 100%" type="text" style="ModernLabelText" text_align="left"/>
|
||||
</object>
|
||||
|
||||
<!-- Separator Line -->
|
||||
<object size="5 224 100%-5 225" type="image" sprite="ModernWhiteLine" z="25"/>
|
||||
|
||||
<!-- Map Size Caption -->
|
||||
<object size="5 225 50% 255" type="image" sprite="ModernItemBackShadeLeft">
|
||||
<object size="0 0 100%-10 100%" type="text" style="ModernLabelText" text_align="right">
|
||||
<translatableAttribute id="caption">Map Size:</translatableAttribute>
|
||||
</object>
|
||||
</object>
|
||||
|
||||
<!-- Map Size Label -->
|
||||
<object size="50% 225 100%-5 255" type="image" sprite="ModernItemBackShadeRight">
|
||||
<object name="sgMapSize" size="0 0 100% 100%" type="text" style="ModernLabelText" text_align="left"/>
|
||||
</object>
|
||||
|
||||
<!-- Separator Line -->
|
||||
<object size="5 254 100%-5 255" type="image" sprite="ModernWhiteLine" z="25"/>
|
||||
|
||||
<!-- Victory Condition Caption -->
|
||||
<object size="5 255 50% 285" type="image" sprite="ModernItemBackShadeLeft">
|
||||
<object size="0 0 100%-10 100%" type="text" style="ModernLabelText" text_align="right">
|
||||
<translatableAttribute id="caption">Victory:</translatableAttribute>
|
||||
</object>
|
||||
</object>
|
||||
|
||||
<!-- Victory Condition Label -->
|
||||
<object size="50% 255 100%-5 285" type="image" sprite="ModernItemBackShadeRight">
|
||||
<object name="sgVictory" size="0 0 100% 100%" type="text" style="ModernLabelText" text_align="left"/>
|
||||
</object>
|
||||
|
||||
<!-- Separator Line -->
|
||||
<object size="5 284 100%-5 285" type="image" sprite="ModernWhiteLine" z="25"/>
|
||||
|
||||
<!-- Map Description Text -->
|
||||
<object type="image" sprite="ModernDarkBoxWhite" size="3% 290 97% 60%">
|
||||
<object name="sgMapDescription" size="0 0 100% 100%" type="text" style="ModernText" font="sans-12"/>
|
||||
</object>
|
||||
|
||||
<object type="image" sprite="ModernDarkBoxWhite" size="3% 60%+5 97% 100%-30">
|
||||
|
||||
<!-- Number of Players Caption-->
|
||||
<object size="0% 3% 57% 12%" type="text" style="ModernRightLabelText">
|
||||
<translatableAttribute id="caption">Players:</translatableAttribute>
|
||||
</object>
|
||||
|
||||
<!-- Number of Players Label-->
|
||||
<object name="sgNbPlayers" size="58% 3% 70% 12%" type="text" style="ModernLeftLabelText" text_align="left"/>
|
||||
|
||||
<!-- Player Names -->
|
||||
<object name="sgPlayersNames" size="0 15% 100% 100%" type="text" style="MapPlayerList"/>
|
||||
</object>
|
||||
|
||||
<!-- "Show Spoiler" Checkbox -->
|
||||
<object name="showSpoiler" type="checkbox" checked="false" style="ModernTickBox" size="10 100%-27 30 100%" font="sans-bold-13">
|
||||
<action on="Press">displayReplayDetails();</action>
|
||||
</object>
|
||||
|
||||
<!-- "Show Spoiler" Label -->
|
||||
<object type="text" size="30 100%-28 100% 100%" text_align="left" textcolor="white">
|
||||
<translatableAttribute id="caption">Spoiler</translatableAttribute>
|
||||
</object>
|
||||
|
||||
</object>
|
||||
</object>
|
||||
|
||||
|
||||
<!-- Bottom Panel: Buttons. -->
|
||||
<object name="bottomPanel" size="25 100%-55 100%-5 100%-25" >
|
||||
|
||||
<!-- Main Menu Button -->
|
||||
<object type="button" style="StoneButton" size="25 0 17%+25 100%">
|
||||
<translatableAttribute id="caption">Main Menu</translatableAttribute>
|
||||
<action on="Press">Engine.SwitchGuiPage("page_pregame.xml");</action>
|
||||
</object>
|
||||
|
||||
<!-- Delete Button -->
|
||||
<object name="deleteReplayButton" type="button" style="StoneButton" size="20%+25 0 37%+25 100%" hotkey="session.savedgames.delete">
|
||||
<translatableAttribute id="caption">Delete</translatableAttribute>
|
||||
<action on="Press">deleteReplayButtonPressed();</action>
|
||||
</object>
|
||||
|
||||
<!-- Summary Button -->
|
||||
<object name="summaryButton" type="button" style="StoneButton" size="65%-50 0 82%-50 100%">
|
||||
<translatableAttribute id="caption">Summary</translatableAttribute>
|
||||
<action on="Press">showReplaySummary();</action>
|
||||
</object>
|
||||
|
||||
<!-- Start Replay Button -->
|
||||
<object name="startReplayButton" type="button" style="StoneButton" size="83%-25 0 100%-25 100%">
|
||||
<translatableAttribute id="caption">Start Replay</translatableAttribute>
|
||||
<action on="Press">startReplay();</action>
|
||||
</object>
|
||||
|
||||
</object>
|
||||
</object>
|
||||
</objects>
|
14
binaries/data/mods/public/gui/replaymenu/styles.xml
Normal file
14
binaries/data/mods/public/gui/replaymenu/styles.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<styles>
|
||||
<style name="MapPlayerList"
|
||||
buffer_zone="8"
|
||||
font="sans-14"
|
||||
scrollbar="true"
|
||||
scrollbar_style="ModernScrollBar"
|
||||
scroll_bottom="true"
|
||||
textcolor="white"
|
||||
text_align="left"
|
||||
text_valign="top"
|
||||
/>
|
||||
</styles>
|
@ -337,19 +337,25 @@ function leaveGame(willRejoin)
|
||||
}
|
||||
}
|
||||
|
||||
let summary = {
|
||||
"timeElapsed" : extendedSimState.timeElapsed,
|
||||
"playerStates": extendedSimState.players,
|
||||
"players": g_Players,
|
||||
"mapSettings": Engine.GetMapSettings(),
|
||||
}
|
||||
|
||||
if (!g_IsReplay)
|
||||
Engine.SaveReplayMetadata(JSON.stringify(summary));
|
||||
|
||||
stopAmbient();
|
||||
Engine.EndGame();
|
||||
|
||||
if (g_IsController && Engine.HasXmppClient())
|
||||
Engine.SendUnregisterGame();
|
||||
|
||||
Engine.SwitchGuiPage("page_summary.xml", {
|
||||
"gameResult" : gameResult,
|
||||
"timeElapsed" : extendedSimState.timeElapsed,
|
||||
"playerStates": extendedSimState.players,
|
||||
"players": g_Players,
|
||||
"mapSettings": mapSettings
|
||||
});
|
||||
summary.gameResult = gameResult;
|
||||
summary.isReplay = g_IsReplay;
|
||||
Engine.SwitchGuiPage("page_summary.xml", summary);
|
||||
}
|
||||
|
||||
// Return some data that we'll use when hotloading this file after changes
|
||||
|
@ -158,7 +158,11 @@
|
||||
<object type="button" style="ModernButtonRed" size="100%-160 100%-48 100%-20 100%-20">
|
||||
<translatableAttribute id="caption">Continue</translatableAttribute>
|
||||
<action on="Press"><![CDATA[
|
||||
if (!Engine.HasXmppClient())
|
||||
if (g_GameData.isReplay)
|
||||
{
|
||||
Engine.SwitchGuiPage("page_replaymenu.xml");
|
||||
}
|
||||
else if (!Engine.HasXmppClient())
|
||||
{
|
||||
Engine.SwitchGuiPage("page_pregame.xml");
|
||||
}
|
||||
|
@ -56,6 +56,7 @@
|
||||
#include "ps/scripting/JSInterface_Console.h"
|
||||
#include "ps/scripting/JSInterface_Mod.h"
|
||||
#include "ps/scripting/JSInterface_VFS.h"
|
||||
#include "ps/scripting/JSInterface_VisualReplay.h"
|
||||
#include "renderer/scripting/JSInterface_Renderer.h"
|
||||
#include "simulation2/Simulation2.h"
|
||||
#include "simulation2/components/ICmpAIManager.h"
|
||||
@ -928,6 +929,7 @@ void GuiScriptingInit(ScriptInterface& scriptInterface)
|
||||
JSI_Sound::RegisterScriptFunctions(scriptInterface);
|
||||
JSI_L10n::RegisterScriptFunctions(scriptInterface);
|
||||
JSI_Lobby::RegisterScriptFunctions(scriptInterface);
|
||||
JSI_VisualReplay::RegisterScriptFunctions(scriptInterface);
|
||||
|
||||
// VFS (external)
|
||||
scriptInterface.RegisterFunction<JS::Value, std::wstring, std::wstring, bool, &JSI_VFS::BuildDirEntList>("BuildDirEntList");
|
||||
|
@ -26,8 +26,10 @@
|
||||
#include "lib/tex/tex.h"
|
||||
#include "ps/Game.h"
|
||||
#include "ps/Loader.h"
|
||||
#include "ps/Mod.h"
|
||||
#include "ps/Profile.h"
|
||||
#include "ps/ProfileViewer.h"
|
||||
#include "ps/Pyrogenesis.h"
|
||||
#include "scriptinterface/ScriptInterface.h"
|
||||
#include "scriptinterface/ScriptStats.h"
|
||||
#include "simulation2/Simulation2.h"
|
||||
@ -63,10 +65,16 @@ CReplayLogger::~CReplayLogger()
|
||||
|
||||
void CReplayLogger::StartGame(JS::MutableHandleValue attribs)
|
||||
{
|
||||
// Add timestamp, since the file-modification-date can change
|
||||
m_ScriptInterface.SetProperty(attribs, "timestamp", std::to_string(std::time(nullptr)));
|
||||
|
||||
// Add engine version and currently loaded mods for sanity checks when replaying
|
||||
m_ScriptInterface.SetProperty(attribs, "engine_version", CStr(engine_version));
|
||||
m_ScriptInterface.SetProperty(attribs, "mods", g_modsLoaded);
|
||||
|
||||
// Construct the directory name based on the PID, to be relatively unique.
|
||||
// Append "-1", "-2" etc if we run multiple matches in a single session,
|
||||
// to avoid accidentally overwriting earlier logs.
|
||||
|
||||
std::wstringstream name;
|
||||
name << getpid();
|
||||
|
||||
@ -74,10 +82,10 @@ void CReplayLogger::StartGame(JS::MutableHandleValue attribs)
|
||||
if (++run)
|
||||
name << "-" << run;
|
||||
|
||||
OsPath path = psLogDir() / L"sim_log" / name.str() / L"commands.txt";
|
||||
CreateDirectories(path.Parent(), 0700);
|
||||
m_Stream = new std::ofstream(OsString(path).c_str(), std::ofstream::out | std::ofstream::trunc);
|
||||
m_Directory = psLogDir() / L"sim_log" / name.str();
|
||||
CreateDirectories(m_Directory, 0700);
|
||||
|
||||
m_Stream = new std::ofstream(OsString(m_Directory / L"commands.txt").c_str(), std::ofstream::out | std::ofstream::trunc);
|
||||
*m_Stream << "start " << m_ScriptInterface.StringifyJSON(attribs, false) << "\n";
|
||||
}
|
||||
|
||||
@ -103,6 +111,11 @@ void CReplayLogger::Hash(const std::string& hash, bool quick)
|
||||
*m_Stream << "hash " << Hexify(hash) << "\n";
|
||||
}
|
||||
|
||||
OsPath CReplayLogger::GetDirectory() const
|
||||
{
|
||||
return m_Directory;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
|
||||
CReplayPlayer::CReplayPlayer() :
|
||||
|
@ -47,6 +47,11 @@ public:
|
||||
* Optional hash of simulation state (for sync checking).
|
||||
*/
|
||||
virtual void Hash(const std::string& hash, bool quick) = 0;
|
||||
|
||||
/**
|
||||
* Remember the directory containing the commands.txt file, so that we can save additional files to it.
|
||||
*/
|
||||
virtual OsPath GetDirectory() const = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -58,6 +63,7 @@ public:
|
||||
virtual void StartGame(JS::MutableHandleValue UNUSED(attribs)) { }
|
||||
virtual void Turn(u32 UNUSED(n), u32 UNUSED(turnLength), std::vector<SimulationCommand>& UNUSED(commands)) { }
|
||||
virtual void Hash(const std::string& UNUSED(hash), bool UNUSED(quick)) { }
|
||||
virtual OsPath GetDirectory() const { return OsPath(); }
|
||||
};
|
||||
|
||||
/**
|
||||
@ -73,10 +79,12 @@ public:
|
||||
virtual void StartGame(JS::MutableHandleValue attribs);
|
||||
virtual void Turn(u32 n, u32 turnLength, std::vector<SimulationCommand>& commands);
|
||||
virtual void Hash(const std::string& hash, bool quick);
|
||||
virtual OsPath GetDirectory() const;
|
||||
|
||||
private:
|
||||
ScriptInterface& m_ScriptInterface;
|
||||
std::ostream* m_Stream;
|
||||
OsPath m_Directory;
|
||||
};
|
||||
|
||||
/**
|
||||
|
305
source/ps/VisualReplay.cpp
Normal file
305
source/ps/VisualReplay.cpp
Normal file
@ -0,0 +1,305 @@
|
||||
/* Copyright (C) 2015 Wildfire Games.
|
||||
* This file is part of 0 A.D.
|
||||
*
|
||||
* 0 A.D. is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* 0 A.D. is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "precompiled.h"
|
||||
|
||||
#include "VisualReplay.h"
|
||||
#include "graphics/GameView.h"
|
||||
#include "gui/GUIManager.h"
|
||||
#include "lib/allocators/shared_ptr.h"
|
||||
#include "lib/utf8.h"
|
||||
#include "ps/CLogger.h"
|
||||
#include "ps/Filesystem.h"
|
||||
#include "ps/Game.h"
|
||||
#include "ps/Pyrogenesis.h"
|
||||
#include "ps/Replay.h"
|
||||
#include "scriptinterface/ScriptInterface.h"
|
||||
|
||||
/**
|
||||
* Filter too short replays (value in seconds).
|
||||
*/
|
||||
const u8 minimumReplayDuration = 3;
|
||||
|
||||
/**
|
||||
* Allows quick debugging of potential platform-dependent file-reading bugs.
|
||||
*/
|
||||
const bool debugParser = false;
|
||||
|
||||
OsPath VisualReplay::GetDirectoryName()
|
||||
{
|
||||
return OsPath(psLogDir() / L"sim_log");
|
||||
}
|
||||
|
||||
JS::Value VisualReplay::GetReplays(ScriptInterface& scriptInterface)
|
||||
{
|
||||
TIMER(L"GetReplays");
|
||||
JSContext* cx = scriptInterface.GetContext();
|
||||
JSAutoRequest rq(cx);
|
||||
|
||||
u32 i = 0;
|
||||
DirectoryNames directories;
|
||||
JS::RootedObject replays(cx, JS_NewArrayObject(cx, 0));
|
||||
if (GetDirectoryEntries(GetDirectoryName(), NULL, &directories) == INFO::OK)
|
||||
for (OsPath& directory : directories)
|
||||
{
|
||||
JS::RootedValue replayData(cx, LoadReplayData(scriptInterface, directory));
|
||||
if (!replayData.isNull())
|
||||
JS_SetElement(cx, replays, i++, replayData);
|
||||
}
|
||||
return JS::ObjectValue(*replays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the cursor backwards until a newline was read or the beginning of the file was found.
|
||||
* Either way the cursor points to the beginning of a newline.
|
||||
*
|
||||
* @return The current cursor position or -1 on error.
|
||||
*/
|
||||
inline int goBackToLineBeginning(std::istream* replayStream, const CStr& fileName, const u64& fileSize)
|
||||
{
|
||||
int currentPos;
|
||||
char character;
|
||||
for (int characters = 0; characters < 10000; ++characters)
|
||||
{
|
||||
currentPos = (int) replayStream->tellg();
|
||||
|
||||
// Stop when reached the beginning of the file
|
||||
if (currentPos == 0)
|
||||
return currentPos;
|
||||
|
||||
if (!replayStream->good())
|
||||
{
|
||||
LOGERROR("Unknown error when returning to the last line (%i of %lu) of %s", currentPos, fileSize, fileName.c_str());
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Stop when reached newline
|
||||
replayStream->get(character);
|
||||
if (character == '\n')
|
||||
return currentPos;
|
||||
|
||||
// Otherwise go back one character.
|
||||
// Notice: -1 will set the cursor back to the most recently read character.
|
||||
replayStream->seekg(-2, std::ios_base::cur);
|
||||
}
|
||||
|
||||
LOGERROR("Infinite loop when going back to a line beginning in %s", fileName.c_str());
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute game duration. Assume constant turn length.
|
||||
* Find the last line that starts with "turn" by reading the file backwards.
|
||||
*
|
||||
* @return seconds or -1 on error
|
||||
*/
|
||||
inline int getReplayDuration(std::istream *replayStream, const CStr& fileName, const u64& fileSize)
|
||||
{
|
||||
CStr type;
|
||||
|
||||
// Move one character before the file-end
|
||||
replayStream->seekg(-2, std::ios_base::end);
|
||||
|
||||
// Infinite loop protection, should never occur.
|
||||
// There should be about 5 lines to read until a turn is found.
|
||||
for (int linesRead = 1; linesRead < 1000; ++linesRead)
|
||||
{
|
||||
int currentPosition = goBackToLineBeginning(replayStream, fileName, fileSize);
|
||||
|
||||
// Read error or reached file beginning. No turns exist.
|
||||
if (currentPosition < 1)
|
||||
return -1;
|
||||
|
||||
if (debugParser)
|
||||
debug_printf("At position %i of %lu after %i lines reads.\n", currentPosition, fileSize, linesRead);
|
||||
|
||||
if (!replayStream->good())
|
||||
{
|
||||
LOGERROR("Read error when determining replay duration at %i of %lu in %s", currentPosition - 2, fileSize, fileName.c_str());
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Found last turn, compute duration.
|
||||
if ((u64) currentPosition + 4 < fileSize && (*replayStream >> type).good() && type == "turn")
|
||||
{
|
||||
u32 turn = 0, turnLength = 0;
|
||||
*replayStream >> turn >> turnLength;
|
||||
return (turn+1) * turnLength / 1000; // add +1 as turn numbers starts with 0
|
||||
}
|
||||
|
||||
// Otherwise move cursor back to the character before the last newline
|
||||
replayStream->seekg(currentPosition - 2, std::ios_base::beg);
|
||||
}
|
||||
|
||||
LOGERROR("Infinite loop when determining replay duration for %s", fileName.c_str());
|
||||
return -1;
|
||||
}
|
||||
|
||||
JS::Value VisualReplay::LoadReplayData(ScriptInterface& scriptInterface, OsPath& directory)
|
||||
{
|
||||
// The directory argument must not be constant, otherwise concatenating will fail
|
||||
const OsPath replayFile = GetDirectoryName() / directory / L"commands.txt";
|
||||
|
||||
if (debugParser)
|
||||
debug_printf("Opening %s\n", utf8_from_wstring(replayFile.string()).c_str());
|
||||
|
||||
if (!FileExists(replayFile))
|
||||
return JSVAL_NULL;
|
||||
|
||||
// Get file size and modification date
|
||||
CFileInfo fileInfo;
|
||||
GetFileInfo(replayFile, &fileInfo);
|
||||
const u64 fileTime = (u64)fileInfo.MTime() & ~1; // skip lowest bit, since zip and FAT don't preserve it (according to CCacheLoader::LooseCachePath)
|
||||
const u64 fileSize = (u64)fileInfo.Size();
|
||||
|
||||
if (fileSize == 0)
|
||||
return JSVAL_NULL;
|
||||
|
||||
// Open file
|
||||
// TODO: enhancement: support unicode when OsString() is properly implemented for windows
|
||||
const CStr fileName = utf8_from_wstring(replayFile.string());
|
||||
std::ifstream* replayStream = new std::ifstream(fileName.c_str());
|
||||
|
||||
// File must begin with "start"
|
||||
CStr type;
|
||||
if (!(*replayStream >> type).good() || type != "start")
|
||||
{
|
||||
LOGERROR("Couldn't open %s. Non-latin characters are not supported yet.", fileName.c_str());
|
||||
SAFE_DELETE(replayStream);
|
||||
return JSVAL_NULL;
|
||||
}
|
||||
|
||||
// Parse header / first line
|
||||
CStr header;
|
||||
std::getline(*replayStream, header);
|
||||
JSContext* cx = scriptInterface.GetContext();
|
||||
JSAutoRequest rq(cx);
|
||||
JS::RootedValue attribs(cx);
|
||||
if (!scriptInterface.ParseJSON(header, &attribs))
|
||||
{
|
||||
LOGERROR("Couldn't parse replay header of %s", fileName.c_str());
|
||||
SAFE_DELETE(replayStream);
|
||||
return JSVAL_NULL;
|
||||
}
|
||||
|
||||
// Ensure "turn" after header
|
||||
if (!(*replayStream >> type).good() || type != "turn")
|
||||
{
|
||||
SAFE_DELETE(replayStream);
|
||||
return JSVAL_NULL; // there are no turns at all
|
||||
}
|
||||
|
||||
// Don't process files of rejoined clients
|
||||
u32 turn = 1;
|
||||
*replayStream >> turn;
|
||||
if (turn != 0)
|
||||
{
|
||||
SAFE_DELETE(replayStream);
|
||||
return JSVAL_NULL;
|
||||
}
|
||||
|
||||
int duration = getReplayDuration(replayStream, fileName, fileSize);
|
||||
|
||||
SAFE_DELETE(replayStream);
|
||||
|
||||
// Ensure minimum duration
|
||||
if (duration < minimumReplayDuration)
|
||||
return JSVAL_NULL;
|
||||
|
||||
// Return the actual data
|
||||
JS::RootedValue replayData(cx);
|
||||
scriptInterface.Eval("({})", &replayData);
|
||||
scriptInterface.SetProperty(replayData, "file", replayFile);
|
||||
scriptInterface.SetProperty(replayData, "directory", directory);
|
||||
scriptInterface.SetProperty(replayData, "filemod_timestamp", std::to_string(fileTime));
|
||||
scriptInterface.SetProperty(replayData, "attribs", attribs);
|
||||
scriptInterface.SetProperty(replayData, "duration", duration);
|
||||
return replayData;
|
||||
}
|
||||
|
||||
bool VisualReplay::DeleteReplay(const CStrW& replayDirectory)
|
||||
{
|
||||
if (replayDirectory.empty())
|
||||
return false;
|
||||
|
||||
const OsPath directory = GetDirectoryName() / replayDirectory;
|
||||
return DirectoryExists(directory) && DeleteDirectory(directory) == INFO::OK;
|
||||
}
|
||||
|
||||
|
||||
JS::Value VisualReplay::GetReplayAttributes(ScriptInterface::CxPrivate* pCxPrivate, const CStrW& directoryName)
|
||||
{
|
||||
// Create empty JS object
|
||||
JSContext* cx = pCxPrivate->pScriptInterface->GetContext();
|
||||
JSAutoRequest rq(cx);
|
||||
JS::RootedValue attribs(cx);
|
||||
pCxPrivate->pScriptInterface->Eval("({})", &attribs);
|
||||
|
||||
// Return empty object if file doesn't exist
|
||||
const OsPath replayFile = GetDirectoryName() / directoryName / L"commands.txt";
|
||||
if (!FileExists(replayFile))
|
||||
return attribs;
|
||||
|
||||
// Open file
|
||||
std::istream* replayStream = new std::ifstream(utf8_from_wstring(replayFile.string()).c_str());
|
||||
CStr type, line;
|
||||
ENSURE((*replayStream >> type).good() && type == "start");
|
||||
|
||||
// Read and return first line
|
||||
std::getline(*replayStream, line);
|
||||
pCxPrivate->pScriptInterface->ParseJSON(line, &attribs);
|
||||
SAFE_DELETE(replayStream);;
|
||||
return attribs;
|
||||
}
|
||||
|
||||
// TODO: enhancement: how to save the data if the process is killed? (case SDL_QUIT in main.cpp)
|
||||
void VisualReplay::SaveReplayMetadata(const CStrW& data)
|
||||
{
|
||||
// TODO: enhancement: use JS::HandleValue similar to SaveGame
|
||||
if (!g_Game)
|
||||
return;
|
||||
|
||||
// Get the directory of the currently active replay
|
||||
const OsPath fileName = g_Game->GetReplayLogger().GetDirectory() / L"metadata.json";
|
||||
CreateDirectories(fileName.Parent(), 0700);
|
||||
|
||||
std::ofstream stream (OsString(fileName).c_str(), std::ofstream::out | std::ofstream::trunc);
|
||||
stream << utf8_from_wstring(data);
|
||||
stream.close();
|
||||
}
|
||||
|
||||
JS::Value VisualReplay::GetReplayMetadata(ScriptInterface::CxPrivate* pCxPrivate, const CStrW& directoryName)
|
||||
{
|
||||
const OsPath filePath = GetDirectoryName() / directoryName / L"metadata.json";
|
||||
|
||||
JSContext* cx = pCxPrivate->pScriptInterface->GetContext();
|
||||
JSAutoRequest rq(cx);
|
||||
JS::RootedValue metadata(cx);
|
||||
|
||||
if (!FileExists(filePath))
|
||||
return JSVAL_NULL;
|
||||
|
||||
std::ifstream* stream = new std::ifstream(OsString(filePath).c_str());
|
||||
ENSURE(stream->good());
|
||||
CStr line;
|
||||
std::getline(*stream, line);
|
||||
stream->close();
|
||||
delete stream;
|
||||
pCxPrivate->pScriptInterface->ParseJSON(line, &metadata);
|
||||
|
||||
return metadata;
|
||||
}
|
78
source/ps/VisualReplay.h
Normal file
78
source/ps/VisualReplay.h
Normal file
@ -0,0 +1,78 @@
|
||||
/* Copyright (C) 2015 Wildfire Games.
|
||||
* This file is part of 0 A.D.
|
||||
*
|
||||
* 0 A.D. is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* 0 A.D. is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#ifndef INCLUDED_REPlAY
|
||||
#define INCLUDED_REPlAY
|
||||
|
||||
#include "scriptinterface/ScriptInterface.h"
|
||||
class CSimulation2;
|
||||
class CGUIManager;
|
||||
|
||||
/**
|
||||
* Contains functions for visually replaying past games.
|
||||
*/
|
||||
namespace VisualReplay
|
||||
{
|
||||
|
||||
/**
|
||||
* Returns the path to the sim-log directory (that contains the directories with the replay files.
|
||||
*
|
||||
* @param scriptInterface the ScriptInterface in which to create the return data.
|
||||
* @return OsPath the absolute file path
|
||||
*/
|
||||
OsPath GetDirectoryName();
|
||||
|
||||
/**
|
||||
* Get a list of replays to display in the GUI.
|
||||
*
|
||||
* @param scriptInterface the ScriptInterface in which to create the return data.
|
||||
* @return array of objects containing replay data
|
||||
*/
|
||||
JS::Value GetReplays(ScriptInterface& scriptInterface);
|
||||
|
||||
/**
|
||||
* Parses a commands.txt file and extracts metadata.
|
||||
* Works similarly to CGame::LoadReplayData().
|
||||
*/
|
||||
JS::Value LoadReplayData(ScriptInterface& scriptInterface, OsPath& directory);
|
||||
|
||||
/**
|
||||
* Permanently deletes the visual replay (including the parent directory)
|
||||
*
|
||||
* @param replayFile path to commands.txt, whose parent directory will be deleted
|
||||
* @return true if deletion was successful, false on error
|
||||
*/
|
||||
bool DeleteReplay(const CStrW& replayFile);
|
||||
|
||||
/**
|
||||
* Returns the parsed header of the replay file (commands.txt).
|
||||
*/
|
||||
JS::Value GetReplayAttributes(ScriptInterface::CxPrivate* pCxPrivate, const CStrW& directoryName);
|
||||
|
||||
/**
|
||||
* Returns the metadata of a replay.
|
||||
*/
|
||||
JS::Value GetReplayMetadata(ScriptInterface::CxPrivate* pCxPrivate, const CStrW& directoryName);
|
||||
|
||||
/**
|
||||
* Saves the metadata from the session to metadata.json
|
||||
*/
|
||||
void SaveReplayMetadata(const CStrW& data);
|
||||
|
||||
}
|
||||
|
||||
#endif
|
Loading…
Reference in New Issue
Block a user