1
1
forked from 0ad/0ad

Implement Single-Player campaigns - Barebones tutorial campaign included.

This implements necessary tooling to create a simple SP campaign.
The architecture is intended to be easily extensible in the future.

'Campaign Run' contains the metadata of a campaign, e.g. maps
played/won. It's saved in the user folder under
saves/campaigns/*.0adcampaign
Campaign templates are JSON files in campaigns/

Campaigns can specify which Menu interface they will use. This is
intended to allow more complex layouts/presentation.
For now, a simple list interface is provided. This allows making
campaigns without any fancy art required (and effectively mimics AoE1's
campaign interface).

The behaviour on game end is also intended to be extensible, supporting
things such as carrying over units between scenarios - for now, it
simply records won games.

GameSetup is not available for now - scenarios are triggered with the
settings defined in the map/default settings. Improving on this requires
refactoring the gamesetup further.

The load/save game page has been extended slightly to support
showing/hiding campaign games (campaign gamed are saved under saves/
directly, there is no strong motivation to do otherwise at this point)

Closes #4387

Differential Revision: https://code.wildfiregames.com/D11
This was SVN commit r24979.
This commit is contained in:
wraitii 2021-03-02 15:43:44 +00:00
parent b7ff2107ea
commit 1c9efa6fb5
39 changed files with 1216 additions and 38 deletions

View File

@ -30,7 +30,6 @@
scrollbar_style="ModernScrollBar"
sprite="ModernDarkBoxGoldNoTop"
sprite_selectarea="ModernDarkBoxWhite"
sprite_heading="ModernDarkBoxGoldNoBottom"
textcolor="white"
textcolor_selected="white"
text_align="left"

View File

@ -101,7 +101,7 @@
</object>
<object name="modsEnabledList"
style="ModernList"
style="ModernSortedList"
type="olist"
size="0 25 100%-32-2-2 100%"
font="sans-stroke-13"

View File

@ -0,0 +1,23 @@
{
"Name": "Tutorial",
"Description": "Learn how to play 0 A.D.",
"Image": "session/icons/mappreview/Introductory_Tutorial.png",
"Levels": {
"introduction": {
"Name": "Introductory Tutorial",
"Map": "tutorials/introductory_tutorial.xml",
"MapType": "scenario",
"Description": "This is a basic tutorial to get you started playing 0 A.D.",
"Preview": "session/icons/mappreview/Introductory_Tutorial.png"
},
"eco_walkthrough": {
"Name": "Economy Walkthrough",
"Map": "tutorials/starting_economy_walkthrough.xml",
"MapType": "scenario",
"Description": "This map will give a rough guide for starting the game effectively. Early in the game the most important thing is to gather resources as fast as possible so you are able to build enough troops later. Warning: This is very fast at the start, be prepared to run through the initial bit several times.",
"Requires": "introduction"
}
},
"Order": ["introduction", "eco_walkthrough"],
"ShowUnavailable": true
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<objects>
<script directory="gui/common/"/>
<script directory="gui/common/campaigns/"/>
</objects>

View File

@ -0,0 +1,162 @@
/**
* This is the main menu screen of the campaign.
* It shows you the currently available scenarios, scenarios you've already completed, etc.
* This particular variant is extremely simple and shows a list similar to Age 1's campaigns,
* but conceptually nothing really prevents more complex systems.
*/
class CampaignMenu extends AutoWatcher
{
constructor(campaignRun)
{
super("render");
this.run = campaignRun;
this.selectedLevel = -1;
this.levelSelection = Engine.GetGUIObjectByName("levelSelection");
this.levelSelection.onSelectionChange = () => { this.selectedLevel = this.levelSelection.selected; };
this.levelSelection.onMouseLeftDoubleClickItem = () => this.startScenario();
Engine.GetGUIObjectByName('startButton').onPress = () => this.startScenario();
Engine.GetGUIObjectByName('backToMain').onPress = () => this.goBackToMainMenu();
Engine.GetGUIObjectByName('savedGamesButton').onPress = () => Engine.PushGuiPage('page_loadgame.xml', {
'campaignRun': this.run.filename
});
this.mapCache = new MapCache();
this._ready = true;
}
goBackToMainMenu()
{
this.run.save();
Engine.SwitchGuiPage("page_pregame.xml", {});
}
startScenario()
{
let level = this.getSelectedLevelData();
if (!meetsRequirements(this.run, level))
return;
Engine.SwitchGuiPage("page_gamesetup.xml", {
"mapType": level.MapType,
"map": "maps/" + level.Map,
"autostart": true,
"campaignData": {
"run": this.run.filename,
"levelID": this.levelSelection.list_data[this.selectedLevel],
"data": this.run.data
}
});
}
getSelectedLevelData()
{
if (this.selectedLevel === -1)
return undefined;
return this.run.template.Levels[this.levelSelection.list_data[this.selectedLevel]];
}
shouldShowLevel(levelData)
{
if (this.run.template.ShowUnavailable)
return true;
return meetsRequirements(this.run, levelData);
}
getLevelName(levelData)
{
if (levelData.Name)
return translateWithContext("Campaign Template", levelData.Name);
return translate(this.mapCache.getTranslatableMapName(levelData.MapType, "maps/" + levelData.Map));
}
getLevelDescription(levelData)
{
if (levelData.Description)
return translateWithContext("Campaign Template", levelData.Description);
return this.mapCache.getTranslatedMapDescription(levelData.MapType, "maps/" + levelData.Map);
}
displayLevelsList()
{
let list = [];
for (let key in this.run.template.Levels)
{
let level = this.run.template.Levels[key];
if (!this.shouldShowLevel(level))
continue;
let status = "";
let name = this.getLevelName(level);
if (isCompleted(this.run, key))
status = translateWithContext("campaign status", "Completed");
else if (meetsRequirements(this.run, level))
status = coloredText(translateWithContext("campaign status", "Available"), "green");
else
name = coloredText(name, "gray");
list.push({ "ID": key, "name": name, "status": status });
}
list.sort((a, b) => this.run.template.Order.indexOf(a.ID) - this.run.template.Order.indexOf(b.ID));
list = prepareForDropdown(list);
this.levelSelection.list_name = list.name || [];
this.levelSelection.list_status = list.status || [];
// COList needs these changed last or crashes.
this.levelSelection.list = list.ID || [];
this.levelSelection.list_data = list.ID || [];
}
displayLevelDetails()
{
if (this.selectedLevel === -1)
{
Engine.GetGUIObjectByName("startButton").enabled = false;
Engine.GetGUIObjectByName("startButton").hidden = false;
return;
}
let level = this.getSelectedLevelData();
Engine.GetGUIObjectByName("scenarioName").caption = this.getLevelName(level);
Engine.GetGUIObjectByName("scenarioDesc").caption = this.getLevelDescription(level);
if (level.Preview)
Engine.GetGUIObjectByName('levelPreviewBox').sprite = "cropped:" + 400/512 + "," + 300/512 + ":" + level.Preview;
else
Engine.GetGUIObjectByName('levelPreviewBox').sprite = "cropped:" + 400/512 + "," + 300/512 + ":session/icons/mappreview/nopreview.png";
Engine.GetGUIObjectByName("startButton").enabled = meetsRequirements(this.run, level);
Engine.GetGUIObjectByName("startButton").hidden = false;
Engine.GetGUIObjectByName("loadSavedButton").hidden = true;
}
render()
{
Engine.GetGUIObjectByName("campaignTitle").caption = this.run.getLabel();
this.displayLevelDetails();
this.displayLevelsList();
}
}
var g_CampaignMenu;
function init(initData)
{
let run;
try {
run = new CampaignRun(initData.filename).load();
} catch (err) {
error(sprintf(translate("Error loading campaign run %s: %s."), initData.filename, err));
Engine.SwitchGuiPage("page_pregame.xml", {});
}
g_CampaignMenu = new CampaignMenu(run);
}

View File

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<objects>
<script file="gui/maps/MapCache.js"/>
<script directory="gui/campaigns/default_menu/"/>
<object type="image" style="ModernWindow" size="0 0 100% 100%" name="campaignMenuWindow">
<!-- Title -->
<object style="ModernLabelText" name="campaignTitle" type="text" size="50%-128 4 50%+128 36">
<translatableAttribute id="caption">Campaign Name</translatableAttribute>
</object>
<object name="leftPanel" size="30 50 100%-447 100%-60">
<!-- List of available levels -->
<object name="levelSelection"
size="0 0 100% 100%"
style="ModernList"
type="olist"
font="sans-stroke-16"
>
<!-- Columns -->
<column id="name" color="172 172 212" width="80%">
<translatableAttribute id="heading" context="campaignLevelList">Scenario Name</translatableAttribute>
</column>
<column id="status" color="192 192 192" width="20%">
<translatableAttribute id="heading" context="campaignLevelList">Status</translatableAttribute>
</column>
</object>
</object>
<object name="rightPanel" size="100%-432 50 100%-30 100%-60">
<!-- Scenario Image -->
<object type="image" sprite="ModernDarkBoxGold" name="levelPreviewBox" size="0 0 100% 302">
<object type="image" sprite="" size="1 1 100%-1 301" name="mapPreview"/>
</object>
<!-- Name and description -->
<object size="0 315 100% 100%">
<object name="scenarioName" type="text" style="TitleText" size="0 0 100% 30">
<translatableAttribute id="caption">No scenario selected</translatableAttribute>
</object>
<object type="image" sprite="ModernDarkBoxGold" size="0 30 100% 100%">
<object name="scenarioDesc" type="text" style="ModernText" size="10 10 100%-10 100%-10"/>
</object>
</object>
</object>
<object name="bottomPanel" size="30 100%-55 100%-5 100%-25" >
<!-- Exit & Back to Main Menu -->
<object name="backToMain" type="button" style="StoneButton" size="0 0 17% 100%">
<translatableAttribute id="caption">Back to Main Menu</translatableAttribute>
</object>
<object name="savedGamesButton" type="button" style="StoneButtonFancy" size="63%-25 0 80%-25 100%">
<translatableAttribute id="caption">Saved Games</translatableAttribute>
</object>
<object name="startButton" type="button" style="StoneButtonFancy" size="83%-25 0 100%-25 100%" enabled="false">
<translatableAttribute id="caption">Start Scenario</translatableAttribute>
</object>
<object name="loadSavedButton" type="button" style="StoneButtonFancy" size="83%-25 0 100%-25 100%" hidden="true">
<translatableAttribute id="caption">Resume Saved Game</translatableAttribute>
</object>
</object>
</object>
</objects>

View File

@ -0,0 +1,11 @@
/**
* This is a transient page, triggered at the end of a game session,
* to perform custom computations on the endgame data.
*/
function init(endGameData)
{
let run = CampaignRun.getCurrentRun();
if (endGameData.won)
markLevelComplete(run, endGameData.levelID);
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<objects>
<script file="gui/campaigns/default_menu/utils.js"/>
<script directory="gui/campaigns/default_menu/endgame/"/>
</objects>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<page>
<include>campaigns/common_scripts.xml</include>
<include>campaigns/default_menu/endgame/endgame.xml</include>
</page>

View File

@ -0,0 +1,13 @@
<?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/sprites.xml</include>
<include>common/styles.xml</include>
<include>campaigns/common_scripts.xml</include>
<include>campaigns/default_menu/CampaignMenu.xml</include>
</page>

View File

@ -0,0 +1,26 @@
/**
* Various utilities.
*/
function markLevelComplete(run, levelID)
{
if (!isCompleted(run, levelID))
{
if (!run.data.completedLevels)
run.data.completedLevels = [];
run.data.completedLevels.push(levelID);
run.save();
}
}
function isCompleted(run, levelID)
{
return run.data.completedLevels && run.data.completedLevels.indexOf(levelID) !== -1;
}
function meetsRequirements(run, levelData)
{
if (!levelData.Requires)
return true;
return MatchesClassList(run.data.completedLevels || [], levelData.Requires);
}

View File

@ -0,0 +1,140 @@
/**
* Mimics CampaignRun's necessary interface, but for a broken run
* (i.e. a run that can't be loaded), thus allowing to delete it.
*/
class BrokenRun
{
constructor(file)
{
this.filename = file;
}
getLabel(forList)
{
if (!forList)
return this.filename + ".0adcampaign";
return coloredText(sprintf("%(filename)s (%(error)s)", {
"filename": this.filename + ".0adcampaign",
"error": translate("file cannot be loaded")
}), "red");
}
destroy()
{
Engine.DeleteCampaignSave("saves/campaigns/" + this.filename + ".0adcampaign");
}
}
/**
* Lets you load/delete/look at existing campaign runs in your user folder.
*/
class LoadModal extends AutoWatcher
{
constructor(campaignTemplate)
{
super("render");
// _watch so render() is called anytime currentRuns are modified.
this.currentRuns = _watch(this.getRuns(), () => this.render());
Engine.GetGUIObjectByName('cancelButton').onPress = () => Engine.SwitchGuiPage("page_pregame.xml", {});
Engine.GetGUIObjectByName('deleteGameButton').onPress = () => this.deleteSelectedRun();
Engine.GetGUIObjectByName('startButton').onPress = () => this.startSelectedRun();
this.noCampaignsText = Engine.GetGUIObjectByName("noCampaignsText");
this.selectedRun = -1;
this.runSelection = Engine.GetGUIObjectByName("runSelection");
this.runSelection.onSelectionChange = () => {
this.selectedRun = this.runSelection.selected;
if (this.selectedRun === -1)
Engine.GetGUIObjectByName('runDescription').caption = "";
else
Engine.GetGUIObjectByName('runDescription').caption = this.currentRuns[this.selectedRun].getLabel();
};
this.runSelection.onMouseLeftDoubleClickItem = () => this.startSelectedRun();
this._ready = true;
}
getRuns()
{
let out = [];
let files = Engine.ListDirectoryFiles("saves/campaigns/", "*.0adcampaign", false);
for (let file of files)
{
let name = file.replace("saves/campaigns/", "").replace(".0adcampaign", "");
try
{
out.push(new CampaignRun(name).load());
}
catch(err)
{
error(err);
out.push(new BrokenRun(name));
}
}
return out;
}
loadCampaign()
{
let filename = this.currentRuns[this.selectedRun].filename;
let run = new CampaignRun(filename)
.load()
.setCurrent();
Engine.SwitchGuiPage(run.getMenuPath(), {
"filename": filename
});
}
deleteSelectedRun()
{
if (this.selectedRun === -1)
return;
let run = this.currentRuns[this.selectedRun];
messageBox(
400, 200,
sprintf(translate("Are you sure you want to delete run %s? This cannot be undone."), run.getLabel()),
translate("Confirmation"),
[translate("No"), translate("Yes")],
[null, () => {
run.destroy();
this.currentRuns.splice(this.selectedRun, 1);
this.selectedRun = -1;
}]
);
}
startSelectedRun()
{
if (this.currentRuns[this.selectedRun] instanceof CampaignRun)
this.loadCampaign();
}
displayCurrentRuns()
{
this.runSelection.list = this.currentRuns.map(run => run.getLabel(true));
this.runSelection.list_data = this.currentRuns.map(run => run.filename);
}
render()
{
this.noCampaignsText.hidden = !!this.currentRuns.length;
Engine.GetGUIObjectByName('deleteGameButton').enabled = this.selectedRun !== -1;
Engine.GetGUIObjectByName('startButton').enabled = this.selectedRun !== -1 && this.currentRuns[this.selectedRun] instanceof CampaignRun;
this.displayCurrentRuns();
}
}
var g_LoadModal;
function init()
{
g_LoadModal = new LoadModal();
}

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<objects>
<!-- TODO: add a way to load the directory we're in ? -->
<script directory="gui/campaigns/load_modal/" />
<!-- Add a translucent black background to fade out the page -->
<object type="image" z="0" sprite="ModernFade"/>
<object type="image" style="ModernDialog" size="50%-300 50%-200 50%+300 50%+200">
<object type="image" z="0" sprite="ModernFade"/>
<object type="text" style="TitleText" size="50%-128 -18 50%+128 14">
<translatableAttribute id="caption">Load Campaign</translatableAttribute>
</object>
<object name="runSelection" style="ModernList" type="list" size="24 22 100%-24 100%-130">
</object>
<object size="24 22 100%-24 100%-130" name="noCampaignsText" type="text" text_align="center" style="ModernLeftLabelText">
<translatableAttribute id="caption">No ongoing campaigns.</translatableAttribute>
</object>
<object size="24 100%-124 100%-24 100%-100" name="campaignSaveName" type="text" style="ModernLeftLabelText">
<translatableAttribute id="caption">Name of selected run:</translatableAttribute>
</object>
<object name="runDescription" size="24 100%-96 100%-24 100%-72" type="input" style="ModernInput">
</object>
<object name="cancelButton" type="button" size="0%+25 100%-60 33%+10 100%-32" style="StoneButton" hotkey="cancel">
<translatableAttribute id="caption">Cancel</translatableAttribute>
</object>
<object name="deleteGameButton" type="button" size="33%+20 100%-60 66%-15 100%-32" style="StoneButton" enabled="false">
<translatableAttribute id="caption">Delete</translatableAttribute>
</object>
<object name="startButton" type="button" style="StoneButton" size="66%-5 100%-60 100%-25 100%-32">
<translatableAttribute id="caption">Load Campaign</translatableAttribute>
</object>
</object>
</objects>

View File

@ -0,0 +1,13 @@
<?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/sprites.xml</include>
<include>common/styles.xml</include>
<include>campaigns/common_scripts.xml</include>
<include>campaigns/load_modal/LoadModal.xml</include>
</page>

View File

@ -0,0 +1,42 @@
/**
* Modal screen that pops up when you start a new campaign from the setup screen.
* asking you to name it.
* Will then create the file with the according name and start everything up.
*/
class NewCampaignModal
{
constructor(campaignTemplate)
{
this.template = campaignTemplate;
Engine.GetGUIObjectByName('cancelButton').onPress = () => Engine.PopGuiPage();
Engine.GetGUIObjectByName('startButton').onPress = () => this.createAndStartCampaign();
Engine.GetGUIObjectByName('runDescription').caption = this.template.Name;
Engine.GetGUIObjectByName('runDescription').onTextEdit = () => {
Engine.GetGUIObjectByName('startButton').enabled = Engine.GetGUIObjectByName('runDescription').caption.length > 0;
};
Engine.GetGUIObjectByName('runDescription').focus();
}
createAndStartCampaign()
{
let filename = this.template.identifier + "_" + Date.now() + "_" + Math.floor(Math.random()*100000);
let run = new CampaignRun(filename)
.setTemplate(this.template)
.setMeta(Engine.GetGUIObjectByName('runDescription').caption)
.save()
.setCurrent();
Engine.SwitchGuiPage(run.getMenuPath(), {
"filename": filename
});
}
}
var g_NewCampaignModal;
function init(campaign_template_data)
{
g_NewCampaignModal = new NewCampaignModal(campaign_template_data);
}

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<objects>
<script directory="gui/campaigns/new_modal/" />
<!-- Add a translucent black background to fade out the page -->
<object type="image" z="0" sprite="ModernFade"/>
<object type="image" style="ModernDialog" size="50%-300 50%-75 50%+300 50%+75">
<object type="image" z="0" sprite="ModernFade"/>
<object type="text" style="TitleText" size="50%-128 -18 50%+128 14">
<translatableAttribute id="caption">Start a campaign</translatableAttribute>
</object>
<object type="text" style="ModernLabelText" text_align="left" size="24 20 100%-24 100%-100">
<translatableAttribute id="caption">Please enter the name of your new campaign run: </translatableAttribute>
</object>
<object name="runDescription" size="24 100%-96 100%-24 100%-72" type="input" style="ModernInput">
</object>
<object name="cancelButton" type="button" size="0%+25 100%-60 33%+10 100%-32" style="StoneButton" hotkey="cancel">
<translatableAttribute id="caption">Cancel</translatableAttribute>
</object>
<object name="startButton" type="button" style="StoneButton" size="66%-5 100%-60 100%-25 100%-32" enabled="true">
<translatableAttribute id="caption">Start Campaign</translatableAttribute>
</object>
</object>
</objects>

View File

@ -0,0 +1,14 @@
<?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/sprites.xml</include>
<include>common/styles.xml</include>
<include>campaigns/common_scripts.xml</include>
<include>campaigns/new_modal/NewCampaignModal.xml</include>
</page>

View File

@ -0,0 +1,71 @@
/**
* The campaign setup page shows you the list of available campaigns,
* some information about them, and lets you start a new one.
*/
class CampaignSetupPage extends AutoWatcher
{
constructor()
{
super("render");
this.selectedIndex = -1;
this.templates = CampaignTemplate.getAvailableTemplates();
Engine.GetGUIObjectByName("mainMenuButton").onPress = () => Engine.SwitchGuiPage("page_pregame.xml");
Engine.GetGUIObjectByName("startCampButton").onPress = () => Engine.PushGuiPage("campaigns/new_modal/page.xml", this.selectedTemplate);
this.campaignSelection = Engine.GetGUIObjectByName("campaignSelection");
this.campaignSelection.onMouseLeftDoubleClickItem = () => {
if (this.selectedIndex === -1)
return;
Engine.PushGuiPage("campaigns/new_modal/page.xml", this.selectedTemplate);
};
this.campaignSelection.onSelectionChange = () => {
this.selectedIndex = this.campaignSelection.selected;
if (this.selectedIndex !== -1)
this.selectedTemplate = this.templates[this.selectedIndex];
else
this.selectedTemplate = null;
};
this._ready = true;
}
displayCampaignDetails()
{
Engine.GetGUIObjectByName("startCampButton").enabled = this.selectedIndex !== -1;
if (!this.selectedTemplate)
{
Engine.GetGUIObjectByName("campaignTitle").caption = translate("No campaign selected.");
Engine.GetGUIObjectByName("campaignDesc").caption = "";
Engine.GetGUIObjectByName("campaignImage").sprite = "cropped:" + 400/512 + "," + 300/512 + ":session/icons/mappreview/nopreview.png";
return;
}
Engine.GetGUIObjectByName("campaignTitle").caption = translateWithContext("Campaign Template", this.selectedTemplate.Name);
Engine.GetGUIObjectByName("campaignDesc").caption = translateWithContext("Campaign Template", this.selectedTemplate.Description);
if ('Image' in this.selectedTemplate)
Engine.GetGUIObjectByName("campaignImage").sprite = "stretched:" + this.selectedTemplate.Image;
else
Engine.GetGUIObjectByName("campaignImage").sprite = "cropped:" + 400/512 + "," + 300/512 + ":session/icons/mappreview/nopreview.png";
}
render()
{
this.displayCampaignDetails();
Engine.GetGUIObjectByName("campaignSelection").list_name = this.templates.map((camp) => translateWithContext("Campaign Template", camp.Name));
// COList needs these changed last or crashes.
Engine.GetGUIObjectByName("campaignSelection").list = this.templates.map((camp) => camp.identifier) || [];
Engine.GetGUIObjectByName("campaignSelection").list_data = this.templates.map((camp) => camp.identifier) || [];
}
}
var g_CampaignSetupPage;
function init()
{
g_CampaignSetupPage = new CampaignSetupPage();
}

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<objects>
<script directory="gui/campaigns/setup/" />
<object type="image" style="ModernWindow" size="0 0 100% 100%" name="campaignWindow">
<!-- Title -->
<object style="ModernLabelText" type="text" size="50%-128 4 50%+128 36">
<translatableAttribute id="caption">Campaigns</translatableAttribute>
</object>
<object name="leftPanel" size="30 50 100%-515 100%-80">
<!-- List of campaigns -->
<object name="campaignSelection"
size="0 0 100% 100%"
style="ModernSortedList"
type="olist"
sortable="true"
selected_column="name"
selected_column_order="-1"
font="sans-stroke-16"
>
<column id="name" color="172 172 212" width="99%">
<translatableAttribute id="heading" context="campaignsetup">Name</translatableAttribute>
</column>
</object>
</object>
<object name="rightPanel" size="100%-485 50 100%-30 100%-30">
<object type="image" sprite="ModernDarkBoxGold" size="0 0 100% 342">
<object name="campaignImage" type="image" sprite="" size="1 1 100%-1 100%-1">
</object>
</object>
<object type="text" name="campaignTitle" style="TitleText" size="0 360 100% 390">
<translatableAttribute id="caption">No Campaign selected</translatableAttribute>
</object>
<object type="image" sprite="ModernDarkBoxGold" name="campaignDescBox" size="0 400 100% 100%-50" hidden="false">
<object type="text" name="campaignDesc" style="ModernLabelText" font="sans-16" size="10 10 100%-10 100%-10">
</object>
</object>
</object>
</object>
<object name="bottomPanel" size="25 100%-55 100%-5 100%-25" >
<!-- Main Menu Button -->
<object name="mainMenuButton" type="button" style="StoneButton" size="25 0 17%+25 100%">
<translatableAttribute id="caption">Main Menu</translatableAttribute>
</object>
<!-- Start Campaign Button -->
<object name="startCampButton" type="button" style="StoneButtonFancy" size="83%-25 0 100%-25 100%" enabled="false">
<translatableAttribute id="caption">Start Campaign</translatableAttribute>
</object>
</object>
</objects>

View File

@ -0,0 +1,13 @@
<?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/sprites.xml</include>
<include>common/styles.xml</include>
<include>campaigns/common_scripts.xml</include>
<include>campaigns/setup/CampaignSetupPage.xml</include>
</page>

View File

@ -0,0 +1,129 @@
// Cached run for CampaignRun.getCurrentRun()
// TODO: Move this to a static member once linters accept it.
var g_CurrentCampaignRun;
/**
* A campaign "Run" saves metadata on a campaign progession.
* It is equivalent to a saved game for a game.
* It is named a "run" in an attempt to disambiguate with saved games from campaign runs,
* campaign templates, and the actual concept of a campaign at large.
*/
class CampaignRun
{
static getCurrentRun()
{
let current = Engine.ConfigDB_GetValue("user", "currentcampaign");
if (g_CurrentCampaignRun && g_CurrentCampaignRun.ID == current)
return g_CurrentCampaignRun.run;
try
{
let run = new CampaignRun(current).load();
g_CurrentCampaignRun = {
"run": run,
"ID": current
};
return run;
}
catch(error)
{
return undefined;
}
}
constructor(name = "")
{
this.filename = name;
// Metadata on the run, such as its description.
this.meta = {};
// 'User' data
this.data = {};
// ID of the campaign templates.
this.template = null;
}
setData(data)
{
if (!data)
{
warn("Invalid campaign scenario end data. Nothing will be saved.");
return this;
}
this.data = data;
this.save();
return this;
}
setTemplate(template)
{
this.template = template;
this.save();
return this;
}
setMeta(description)
{
this.meta.userDescription = description;
this.save();
return this;
}
setCurrent()
{
Engine.ConfigDB_CreateValue("user", "currentcampaign", this.filename);
Engine.ConfigDB_WriteValueToFile("user", "currentcampaign", this.filename, "config/user.cfg");
return this;
}
getMenuPath()
{
return "campaigns/" + this.template.interface + "/page.xml";
}
getEndGamePath()
{
return "campaigns/" + this.template.interface + "/endgame/page.xml";
}
/**
* @param forlist - if true, generate a label for listing all runs.
* Otherwise, just return a short human readable name.
* (not currently used for regular runs).
*/
getLabel(forList)
{
return sprintf(translate("%(userDesc)s - %(templateName)s"), {
"userDesc": this.meta.userDescription,
"templateName": this.template.Name
});
}
load()
{
if (!Engine.FileExists("saves/campaigns/" + this.filename + ".0adcampaign"))
throw new Error("Campaign file does not exist");
let data = Engine.ReadJSONFile("saves/campaigns/" + this.filename + ".0adcampaign");
this.data = data.data;
this.meta = data.meta;
this.template = CampaignTemplate.getTemplate(data.template_identifier);
if (!this.template)
throw new Error("Campaign template " + data.template_identifier + " does not exist (perhaps it comes from a mod?)");
return this;
}
save()
{
let data = {
"data": this.data,
"meta": this.meta,
"template_identifier": this.template.identifier
};
Engine.WriteJSONFile("saves/campaigns/" + this.filename + ".0adcampaign", data);
return this;
}
destroy()
{
Engine.DeleteCampaignSave("saves/campaigns/" + this.filename + ".0adcampaign");
}
}

View File

@ -0,0 +1,54 @@
// TODO: Move this to a static member once linters accept it.
var g_CachedTemplates;
class CampaignTemplate
{
/**
* @returns a dictionary of campaign templates, as [ { 'identifier': id, 'data': data }, ... ]
*/
static getAvailableTemplates()
{
if (g_CachedTemplates)
return g_CachedTemplates;
let campaigns = Engine.ListDirectoryFiles("campaigns/", "*.json", false);
g_CachedTemplates = [];
for (let filename of campaigns)
// Use file name as identifier to guarantee unicity.
g_CachedTemplates.push(new CampaignTemplate(filename.slice("campaigns/".length, -".json".length)));
return g_CachedTemplates;
}
static getTemplate(identifier)
{
if (!g_CachedTemplates)
CampaignTemplate.getAvailableTemplates();
let temp = g_CachedTemplates.filter(t => t.identifier == identifier);
if (!temp.length)
return null;
return temp[0];
}
constructor(identifier)
{
Object.assign(this, Engine.ReadJSONFile("campaigns/" + identifier + ".json"));
this.identifier = identifier;
if (this.Interface)
this.interface = this.Interface;
else
this.interface = "default_menu";
if (!this.isValid())
throw ("Campaign template " + this.identifier + ".json is not a valid campaign template.");
}
isValid()
{
return this.Name;
}
}

View File

@ -0,0 +1,38 @@
/**
* Wrap object in a proxy, that calls callback
* anytime a property is set, passing the property name as parameter.
* Note that this doesn't modify any variable that pointer towards object,
* so this is _not_ equivalent to replacing the target object with a proxy.
*/
function _watch(object, callback)
{
return new Proxy(object, {
"get": (obj, key) => {
return obj[key];
},
"set": (obj, key, value) => {
obj[key] = value;
callback(key);
return true;
}
});
}
/**
* Inherit from AutoWatcher to make 'this' a proxy object that
* watches for its own property changes and calls the method given
* (takes a string because 'this' is unavailable when calling 'super').
* This can be used to e.g. automatically call a rendering function
* if a property is changed.
* Using inheritance is necessary because 'this' is immutable,
* and isn't defined in the class constructor _unless_ super() is called.
* (thus you can't do something like this = new Proxy(this) at the end).
*/
class AutoWatcher
{
constructor(method_name)
{
this._ready = false;
return _watch(this, () => this._ready && this[method_name]());
}
}

View File

@ -106,20 +106,18 @@ class GameSettingsControl
{
if (initData && initData.map && initData.mapType)
{
Object.defineProperty(this, "autostart", {
"value": true,
"writable": false,
"configurable": false
});
if (initData.autostart)
Object.defineProperty(this, "autostart", {
"value": true,
"writable": false,
"configurable": false
});
// TODO: Fix g_GameAttributes, g_GameAttributes.settings,
// g_GameAttributes.settings.PlayerData object references and
// copy over each attribute individually when receiving
// settings from the server or the local file.
g_GameAttributes = {
"mapType": initData.mapType,
"map": initData.map
};
g_GameAttributes = initData;
this.updateGameAttributes();
// Don't launchGame before all Load handlers finished

View File

@ -8,11 +8,18 @@
*/
class SavegameList
{
constructor()
constructor(campaignRun)
{
this.savedGamesMetadata = [];
this.selectionChangeHandlers = [];
// If not null, only show games for the following campaign run
// (campaign save-games are not shown by default).
// Campaign games are saved in the same folder as regular ones,
// as there is no strong reason to do otherwise (since games from different runs
// need to be hidden from one another anyways, we need code to handle it).
this.campaignRun = campaignRun;
this.gameSelection = Engine.GetGUIObjectByName("gameSelection");
this.gameSelectionFeedback = Engine.GetGUIObjectByName("gameSelectionFeedback");
this.confirmButton = Engine.GetGUIObjectByName("confirmButton");
@ -67,7 +74,13 @@ class SavegameList
let engineInfo = Engine.GetEngineInfo();
if (this.compatibilityFilter.checked)
savedGames = savedGames.filter(game => this.isCompatibleSavegame(game.metadata, engineInfo));
savedGames = savedGames.filter(game => {
return this.isCompatibleSavegame(game.metadata, engineInfo) &&
this.campaignFilter(game.metadata, this.campaignRun);
});
else if (this.campaignRun)
savedGames = savedGames.filter(game => this.campaignFilter(game.metadata, this.campaignRun));
this.gameSelection.enabled = !!savedGames.length;
this.gameSelectionFeedback.hidden = !!savedGames.length;
@ -114,7 +127,8 @@ class SavegameList
});
let list = this.savedGamesMetadata.map(metadata => {
let isCompatible = this.isCompatibleSavegame(metadata, engineInfo);
let isCompatible = this.isCompatibleSavegame(metadata, engineInfo) &&
this.campaignFilter(metadata, this.campaignRun);
return {
"date": this.generateSavegameDateString(metadata, engineInfo),
"mapName": compatibilityColor(translate(metadata.initAttributes.settings.Name), isCompatible),
@ -144,6 +158,15 @@ class SavegameList
this.gameSelection.selected = this.savedGamesMetadata.length - 1;
}
campaignFilter(metadata, campaignRun)
{
if (!campaignRun)
return !metadata.initAttributes.campaignData;
if (metadata.initAttributes.campaignData)
return metadata.initAttributes.campaignData.run == campaignRun;
return false;
}
isCompatibleSavegame(metadata, engineInfo)
{
return engineInfo &&

View File

@ -1,14 +1,3 @@
/**
* This class architecture is an example of how to use classes
* to encapsulate and to avoid fragmentation and globals.
*/
var g_SavegamePage;
function init(data)
{
g_SavegamePage = new SavegamePage(data);
}
/**
* This class is responsible for loading the affected GUI control classes,
* and setting them up to communicate with each other.
@ -17,7 +6,7 @@ class SavegamePage
{
constructor(data)
{
this.savegameList = new SavegameList();
this.savegameList = new SavegameList(data && data.campaignRun || null);
this.savegameDetails = new SavegameDetails();
this.savegameList.registerSelectionChangeHandler(this.savegameDetails);
@ -46,3 +35,10 @@ class SavegamePage
Engine.GetGUIObjectByName("cancel").onPress = () => { Engine.PopGuiPage(); };
}
}
var g_SavegamePage;
function init(data)
{
g_SavegamePage = new SavegamePage(data);
}

View File

@ -38,7 +38,7 @@ class MainMenuItemHandler
0, 0, 100, 0);
button.caption = item.caption;
button.tooltip = item.tooltip;
button.enabled = item.enabled === undefined || item.enabled;
button.enabled = item.enabled === undefined || item.enabled();
button.onPress = this.pressButton.bind(this, item, i);
button.hidden = false;
});

View File

@ -15,6 +15,7 @@ var g_MainMenuItems = [
"tooltip": translate("Start the economic tutorial."),
"onPress": () => {
Engine.SwitchGuiPage("page_gamesetup.xml", {
"autostart": true,
"mapType": "scenario",
"map": "maps/tutorials/starting_economy_walkthrough"
});
@ -60,6 +61,16 @@ var g_MainMenuItems = [
}
]
},
{
"caption": translate("Continue Campaign"),
"tooltip": translate("Relive history through historical military campaigns."),
"onPress": () => {
Engine.SwitchGuiPage(CampaignRun.getCurrentRun().getMenuPath(), {
"filename": CampaignRun.getCurrentRun().filename
});
},
"enabled": () => !!CampaignRun.getCurrentRun()
},
{
"caption": translate("Single-player"),
"tooltip": translate("Start, load, or replay a single-player game."),
@ -71,11 +82,6 @@ var g_MainMenuItems = [
Engine.SwitchGuiPage("page_gamesetup.xml");
}
},
{
"caption": translate("Campaigns"),
"tooltip": translate("Relive history through historical military campaigns. \\[NOT YET IMPLEMENTED]"),
"enabled": false
},
{
"caption": translate("Load Game"),
"tooltip": translate("Load a saved game."),
@ -83,6 +89,33 @@ var g_MainMenuItems = [
Engine.PushGuiPage("page_loadgame.xml");
}
},
{
"caption": translate("Continue Campaign"),
"tooltip": translate("Relive history through historical military campaigns."),
"onPress": () => {
Engine.SwitchGuiPage(CampaignRun.getCurrentRun().getMenuPath(), {
"filename": CampaignRun.getCurrentRun().filename
});
},
"enabled": () => !!CampaignRun.getCurrentRun()
},
{
"caption": translate("New Campaign"),
"tooltip": translate("Relive history through historical military campaigns."),
"onPress": () => {
Engine.SwitchGuiPage("campaigns/setup/page.xml");
}
},
{
"caption": translate("Load Campaign"),
"tooltip": translate("Relive history through historical military campaigns."),
"onPress": () => {
// Switch instead of push, otherwise the 'continue'
// button might remain enabled.
// TODO: find a better solution.
Engine.SwitchGuiPage("campaigns/load_modal/page.xml");
}
},
{
"caption": translate("Replays"),
"tooltip": translate("Playback previous games."),
@ -126,7 +159,7 @@ var g_MainMenuItems = [
"tooltip":
colorizeHotkey(translate("%(hotkey)s: Launch the multiplayer lobby to join and host publicly visible games and chat with other players."), "lobby") +
(Engine.StartXmppClient ? "" : translate("Launch the multiplayer lobby. \\[DISABLED BY BUILD]")),
"enabled": !!Engine.StartXmppClient,
"enabled": () => !!Engine.StartXmppClient,
"hotkey": "lobby",
"onPress": () => {
if (Engine.StartXmppClient)

View File

@ -2,6 +2,7 @@
<objects>
<script directory="gui/common/"/>
<script directory="gui/common/campaigns/"/>
<script directory="gui/pregame/"/>
<script directory="gui/pregame/backgrounds/"/>
<script directory="gui/pregame/userreport/"/>

View File

@ -61,7 +61,10 @@ MenuButtons.prototype.Save = class
Engine.PushGuiPage(
"page_loadgame.xml",
{ "savedGameData": getSavedGameData() },
{
"savedGameData": getSavedGameData(),
"campaignRun": g_CampaignSession ? g_CampaignSession.run.filename : null
},
resumeGame);
}
};

View File

@ -0,0 +1,42 @@
class CampaignSession
{
constructor(data)
{
this.run = new CampaignRun(data.run).load();
this.levelID = data.levelID;
registerPlayersFinishedHandler(this.onFinish.bind(this));
this.endGameData = {
"levelID": data.levelID,
"won": false,
"custom": {}
};
}
onFinish(players, won)
{
let playerID = Engine.GetPlayerID();
if (players.indexOf(playerID) === -1)
return;
this.endGameData.custom = Engine.GuiInterfaceCall("GetCampaignGameEndData", {
"player": playerID
});
this.endGameData.won = won;
// Run the endgame script.
Engine.PushGuiPage(this.getEndGame(), this.endGameData);
Engine.PopGuiPage();
}
getMenu()
{
return this.run.getMenuPath();
}
getEndGame()
{
return this.run.getEndGamePath();
}
}
var g_CampaignSession;

View File

@ -266,6 +266,9 @@ function init(initData, hotloadData)
restoreSavedGameData(initData.savedGUIData);
}
if (g_GameAttributes.campaignData)
g_CampaignSession = new CampaignSession(g_GameAttributes.campaignData);
let mapCache = new MapCache();
g_Cheats = new Cheats();
g_DiplomacyColors = new DiplomacyColors();
@ -524,7 +527,7 @@ function endGame()
if (g_IsController && Engine.HasXmppClient())
Engine.SendUnregisterGame();
Engine.SwitchGuiPage("page_summary.xml", {
let summaryData = {
"sim": simData,
"gui": {
"dialog": false,
@ -534,7 +537,21 @@ function endGame()
"replayDirectory": !g_HasRejoined && replayDirectory,
"replaySelectionData": g_ReplaySelectionData
}
});
};
if (g_GameAttributes.campaignData)
{
let menu = g_CampaignSession.getMenu();
if (g_GameAttributes.campaignData.skipSummary)
{
Engine.SwitchGuiPage(menu);
return;
}
summaryData.campaignData = { "filename": g_GameAttributes.campaignData.run };
summaryData.nextPage = menu;
}
Engine.SwitchGuiPage("page_summary.xml", summaryData);
}
// Return some data that we'll use when hotloading this file after changes

View File

@ -3,8 +3,10 @@
<objects>
<script directory="gui/common/"/>
<script directory="gui/common/campaigns/"/>
<script directory="gui/maps/"/>
<script directory="gui/session/"/>
<script directory="gui/session/campaigns/"/>
<script directory="gui/session/chat/"/>
<script directory="gui/session/developer_overlay/"/>
<script directory="gui/session/diplomacy/"/>

View File

@ -473,6 +473,8 @@ function continueButton()
"replaySelectionData": g_GameData.gui.replaySelectionData,
"summarySelection": summarySelection
});
else if (g_GameData.campaignData)
Engine.SwitchGuiPage(g_GameData.nextPage, g_GameData.campaignData);
else
Engine.SwitchGuiPage("page_pregame.xml");
}

View File

@ -31,6 +31,11 @@ file_filter = <lang>.public-gui-other.po
source_file = public-gui-other.pot
source_lang = en
[0ad.public-gui-campaigns]
file_filter = <lang>.public-gui-campaigns.po
source_file = public-gui-campaigns.pot
source_lang = en
[0ad.public-gui-userreport]
file_filter = <lang>.public-gui-userreport.po
source_file = public-gui-userreport.pot

View File

@ -240,6 +240,64 @@
}
]
},
{
"output": "public-gui-campaigns.pot",
"inputRoot": "..",
"project": "0 A.D. — Empires Ascendant",
"copyrightHolder": "Wildfire Games",
"rules": [
{
"extractor": "javascript",
"filemasks": [
"gui/campaigns/**.js",
"gui/common/campaigns/**.js"
],
"options": {
"format": "javascript-format",
"keywords": {
"translate": [1],
"translatePlural": [1, 2],
"translateWithContext": [[1], 2],
"translatePluralWithContext": [[1], 2, 3],
"markForTranslation": [1],
"markForTranslationWithContext": [[1], 2],
"markForPluralTranslation": [1, 2]
},
"commentTags": [
"Translation:"
]
}
},
{
"extractor": "xml",
"filemasks": [
"gui/campaigns/**.xml",
"gui/common/campaigns/**.xml"
],
"options": {
"keywords": {
"translatableAttribute": {
"locationAttributes": ["id"]
},
"translate": {}
}
}
},
{
"extractor": "json",
"filemasks": [
"campaigns/**.json"
],
"options": {
"keywords": [
"Name",
"Description"
],
"context": "Campaign Template"
}
}
]
},
{
"output": "public-gui-other.pot",
"inputRoot": "..",

View File

@ -211,6 +211,17 @@ GuiInterface.prototype.GetReplayMetadata = function()
};
};
/**
* Called when the game ends if the current game is part of a campaign run.
*/
GuiInterface.prototype.GetCampaignGameEndData = function(player)
{
let cmpTrigger = Engine.QueryInterface(SYSTEM_ENTITY, IID_Trigger);
if (Trigger.prototype.OnCampaignGameEnd)
return Trigger.prototype.OnCampaignGameEnd();
return {};
};
GuiInterface.prototype.GetRenamedEntities = function(player)
{
if (this.miragedEntities[player])
@ -2012,6 +2023,7 @@ let exposedFunctions = {
"GetExtendedSimulationState": 1,
"GetInitAttributes": 1,
"GetReplayMetadata": 1,
"GetCampaignGameEndData": 1,
"GetRenamedEntities": 1,
"ClearRenamedEntities": 1,
"GetEntityState": 1,

View File

@ -1,4 +1,4 @@
/* Copyright (C) 2020 Wildfire Games.
/* Copyright (C) 2021 Wildfire Games.
* This file is part of 0 A.D.
*
* 0 A.D. is free software: you can redistribute it and/or modify
@ -209,6 +209,18 @@ void JSI_VFS::WriteJSONFile(ScriptInterface::CmptPrivate* pCmptPrivate, const st
g_VFS->CreateFile(path, buf.Data(), buf.Size());
}
bool JSI_VFS::DeleteCampaignSave(ScriptInterface::CmptPrivate* UNUSED(pCmptPrivate), const CStrW& filePath)
{
OsPath realPath;
if (filePath.Left(16) != L"saves/campaigns/" || filePath.Right(12) != L".0adcampaign")
return false;
return VfsFileExists(filePath) &&
g_VFS->GetRealPath(filePath, realPath) == INFO::OK &&
g_VFS->RemoveFile(filePath) == INFO::OK &&
wunlink(realPath) == 0;
}
bool JSI_VFS::PathRestrictionMet(ScriptInterface::CmptPrivate* pCmptPrivate, const std::vector<CStrW>& validPaths, const CStrW& filePath)
{
for (const CStrW& validPath : validPaths)
@ -259,6 +271,7 @@ void JSI_VFS::RegisterScriptFunctions_GUI(const ScriptInterface& scriptInterface
scriptInterface.RegisterFunction<JS::Value, std::wstring, &JSI_VFS::ReadFileLines>("ReadFileLines");
scriptInterface.RegisterFunction<JS::Value, std::wstring, &Script_ReadJSONFile_GUI>("ReadJSONFile");
scriptInterface.RegisterFunction<void, std::wstring, JS::HandleValue, &WriteJSONFile>("WriteJSONFile");
scriptInterface.RegisterFunction<bool, CStrW, &DeleteCampaignSave>("DeleteCampaignSave");
}
void JSI_VFS::RegisterScriptFunctions_Simulation(const ScriptInterface& scriptInterface)

View File

@ -1,4 +1,4 @@
/* Copyright (C) 2018 Wildfire Games.
/* Copyright (C) 2021 Wildfire Games.
* This file is part of 0 A.D.
*
* 0 A.D. is free software: you can redistribute it and/or modify
@ -47,6 +47,10 @@ namespace JSI_VFS
// Save given JS Object to a JSON file
void WriteJSONFile(ScriptInterface::CmptPrivate* pCmptPrivate, const std::wstring& filePath, JS::HandleValue val1);
// Delete the given campaign save.
// This is limited to campaign save to avoid mods deleting the wrong file.
bool DeleteCampaignSave(ScriptInterface::CmptPrivate* pCmptPrivate, const CStrW& filePath);
// Tests whether the current script context is allowed to read from the given directory
bool PathRestrictionMet(ScriptInterface::CmptPrivate* pCmptPrivate, const std::vector<CStrW>& validPaths, const CStrW& filePath);