An awesome Visual Replay menu, made by elexis. Fixes #3258.

This was SVN commit r17054.
This commit is contained in:
Nicolas Auvray 2015-09-21 17:00:21 +00:00
parent 7002af5b9c
commit d64b95b28c
15 changed files with 1467 additions and 16 deletions

View File

@ -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");
}

View 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>

View File

@ -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>

View 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();
}

View 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;
}

View 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");
}

View 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>

View 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>

View File

@ -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

View File

@ -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");
}

View File

@ -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");

View File

@ -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() :

View File

@ -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
View 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
View 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