Before writing any scripts, let's have a look at the folder structure you'll be working with. There are quite a lot of places for various types of files, so just remember this is here and use it as a reference.
Path | Role |
---|---|
assets/tilemaps | the target folder for your Tiled tilemaps |
assets/tilesets | the target folder for your Tiled tilesets |
assets/locales | translations files are located there |
assets/videos | root folder for video files |
assets/audio | root folder for audio files |
assets/sprites | root folder for spritesheets |
scripts/cmap/traits | CMAP traits plugins folder |
scripts/cmap/races | CMAP races plugins folder |
scripts/cmap/perks | CMAP perks plugins folder |
scripts/quests | Quest folder |
scripts/dialogs | Dialog folder |
scripts/levels | Level-context scripts (each level can implement one) |
scripts/pnjs | Characters scripts |
scripts/items | InventoryItems scripts |
scripts/behaviour | All other DynamicObjects scripts |
Let's also have a look at a series of useful files:
Path | Role |
---|---|
assets/audio.json | maps audio files to nicknames |
assets/musics.json | set of playlists prepared for certain contexts (specific level, worldmap, main menu) |
assets/sprites.json | library of animation and animations groups (should be edited using the Game Editor) |
assets/objects.json | library of available InventoryItems' item types. |
assets/worldmap.png | Worldmap source |
scripts/main.mjs | Game-context script |
scripts/initialize.mjs | A simple script that initializes the Game context once a new game starts. |
The first thing you want to do is to take control of the entry point of the game. You want the player to start at a specific place in the WorldMap, and possibly to start directly inside a Level.
To achieve such results, we will use the scripts/initialize.mjs file. It gets triggered once the players finishes character creation, allowing you to insert any sort of transition and set up the beginning of the game.
The initialize.mjs is a very simple script, and must only export a single initialize function, as such:
export function initialize() {
game.onCityEntered("stable-cavern", "demo-begin");
game.worldmap.setPosition(150, 250);
game.transitionRequired("intro.mp4", 1);
}
This scripts does three things:
game is a global variable pointing to the main game object. It is available everywhere, and can be used at any time by scripts to move the player to another area, move ahead in time, or start playing a video.
The Game script is located at scripts/main.mjs. It is bounded to the Game context, and as such, gets loaded whenever a game starts, and keeps running until the game is exited or over.
The following is the basic structure for all the Game, Level and DynamicObject scripts:
class Game {
constructor(model) {
}
initialize() {
}
}
export function create(model) {
return Game(model);
}
All game-related scripts must export a create function. The create function takes a model parameter, representing the instance of the managed object. The function must return an instance to a JavaScript object.
All game-related scripts may implement an initialize method. While the constructor will be called everytime an object is instantiated, the initialize method will only be called once, after the first instantiation. Since game objects properties are automatically saved by the game engine, it is best to set their initial value here, if needed.
Note that the managed object (model) for the Game script is the main game object itself, meaning that in this context, model is equal to the game global variable.
Often, you will need to save data that will remain accessible as the player exits a level, save and load a game, and so on...
And the thing is, while properties on game objects* are always saved by the game engine, the properties that you set on your scripts aren't saved between multiple loads of your script.
Game objects are the native objects provided by the game engine itself. The parameter received by in the export function create(model) function is an example of game object.
To answer all your data saving needs, all game objects come with their own data store, meaning that you can save data globally, using the game global property. You can also save data on a specific level, using the level global property. And you can also save data on specific characters, items, objects of all kinds.
Using the data storage features is pretty easy: there are only three method names you need to memorize. We will present the three of those in the following snippet:
class Game {
constructor(model) {
this.model = model;
if (this.model.hasVariable("loadCount")) {
const loadCount = this.model.getVariable("loadCount");
this.model.setVariable("loadCount", loadCount + 1);
console.log(model, "load count is", loadCount);
}
}
initialize() {
this.model.setVariable("loadCount", 1);
}
}
export function create(model) {
return Game(model);
}
Let us add some action in that Game script: we will use the TaskManager to create a task that gets called every 5 seconds, and have the light zone we created earlier switch on and off:
export class Game {
initialize() {
game.tasks.addTask("myTask", 5000, 0);
}
myTask() {
if (game.level) {
const myLight = level.tilemap.getLightLayer("my-light");
if (myLight) {
myLight.visible = !myLight.visible;
game.appendToConsole(myLight.visible ? "Lights on !" : "Lights off");
}
}
}
}
export function create(model) {
return new Game(model);
}
In this snippet, we use the game global object tasks property to access the object's TaskManager, and call addTask to schedule a new task.
Keep in mind that the TaskManager does not necessarily call a method from the script that calls addTask: it will try to call that method on the script of the object that owns the TaskManager.
Lastly, we call game.appendToConsole to display a message on the player's HUD console.
Now, here's the result of our new initialize.mjs and main.mjs scripts:
CMAP is a sub-system of the game engine that handles character sheets. Each character includes a CMAP powered character sheet, accessible through the statistics property on Character game objects. It features a set of different values for:
All of these values can be consulted and modified by scripts. Additionally, traits, races and perks are script-based, and entirely customizable.
All the values can be directly read or overwritten from the character sheet object, accessing them using their full name in lower camelcase, such as:
character.statistics.agility
character.statistics.lockpick
character.statistics.armorClass
...
Among other things, CMAP handles the experience and level of a character. You may manually grant experience to a character with the following code:
character.statistics.addExperience(25);
You can also read the current experience, and the experience left until the next level, using the following properties:
character.statistics.experience
character.statistics.xpNextLevel
When the next level is reached, the player gains skill points, and sometimes a perk, according to statistics.skillRate and statistics.perkRate, respectively, the amount of skill points gained by level, and the amount of level to pass before gaining a perk.
The current amount of skill points and perks available to a player can also be directly edited using the following properties:
character.statistics.skillPoints
character.statistics.availablePerks
Traits scripts are located in scripts/cmap/traits. To add a new custom trait, let's create a new file in there:
export function onToggled(characterSheet, toggled) {
if (toggled)
characterSheet.strength -= 3;
else
characterSheet.strength += 3;
}
export function modifyBaseStatistics(characterSheet, name, value) {
if (name == "skillRate")
return value + 10;
return value;
}
export function modifyBaseSkill(characterSheet, name, value) {
return value + 25;
}
In this script, we exported all the functions available to traits:
Base values are computed using SPECIAL points, traits and race. Base values get updated everytime SPECIAL, traits or race are modified.
In our onToggled function, we decrease the character's strength on activation, and increase it on deactivation.
Our modifyBaseStatistics function increase the character's skillRate by ten, and leave other statistics as is.
And our modifyBaseSkill function increases each of the character's skill by 25 points.
Perk scripts are located in scripts/cmap/perks. We will now create a simple perk with the following code:
export function onToggled(characterSheet, toggled) {
const modifier = toggled ? 1 : -1;
characterSheet.charisma += (1 * modifier);
characterSheet.speech += (10 * modifier);
}
export function isAvailableFor(characterSheet) {
return characterSheet.level > 2;
}
You already know the onToggled function, as it is the same as for Traits scripts. In this perk example, we use it to modify the character's charisma characteristic and speech skill. Note that modifyBaseSkill and modifyBaseStatistics are also available in perks.
The important part here is isAvailableFor. Perks can only be picked if certain conditions are met, and this function will be called everytime the perk picking screen appears to figure out if your perk will be part of the options available to the player. In this script, we ensured that the perk wouldn't be available until the player reaches level 3.
Races scripts are located in scripts/cmap/races. Let's create a new race script:
export const isPlayable = false;
export const spriteSheet = "earthpony-green";
export function onToggled(characterSheet, toggled) {
const modifier = toggled ? 1 : -1;
characterSheet.endurance -= (1 * modifier);
characterSheet.strength -= (1 * modifier);
}
Let's go over the exported values from that script:
What if a same race should use several sprite groups ? Neverfear, you can also export spriteSheet as a function, such as:
export function spriteSheet(characterSheet) {
if (characterSheet.gender == "male")
return "ghoul-male";
return "ghoul-female";
}
Quests are basically a set of objective that the player can achieve or fail to achieve. Quest scripts are located in the scripts/quests folder. Let's create our own quest:
class MyQuest {
constructor(model) {
this.model = model;
}
initialize() {
game.appendToConsole("You picked up a new quest !");
}
getObjectives() {
return [
{label: "Objectif lune", success: false},
{label: "Failed objective", failed: true },
{label: "Completed objective", success: true }
];
}
}
export function create(model) {
return new MyQuest(model);
}
Quest scripts are similar to other game object scripts. We added the getObjectives method to return a list of objectives to be displayed to the user. Objectives have three possible state: pending, failure or success (presented in the same order in our method).
Now that we can create our own quests, we should know how to add these quests to the player's quest log. We will see how to add a quest, using the initialize.mjs script as an example:
export function initialize() {
game.quests.addQuest("myQuest"); // <-- here we go
game.onCityEntered("stable-cavern", "demo-begin");
game.worldmap.setPosition(150, 250);
game.transitionRequired("intro.mp4", 1);
}
Here, we use the QuestManager, accessbile using game.quests to add our myQuest quest to the player's active quests. The quest manager can also be used to fetch a specific quest object, or iterate over all active quests, using respectively game.quests.getQuest("myQuest") and game.quests.list.
Now, how would we go around completing our objectives ? Well, this is mainly left to the script to decide. First, we must implement the completeObjective method:
class MyQuest {
constructor(model) {
this.model = model;
}
completeObjective(objectiveName) {
this.model.setVariable(objectiveName, 1);
this.model.completed = true;
}
getObjectives() {
return [
{label: "Objectif lune", success: this.model.getVariable("myObjective") == 1}
]
}
onCompleted() {
game.player.statistics.addExperience(1000);
game.appendToConsole("You gained 1000 experience points for completing MyQuest");
}
}
In our completeObjective, we persist the state of our objective using setVariable on the quest object. Since our quest only includes one objective, we also set the completed property to true on the quest object.
We also added the onCompleted method, which gets called whenever this.model.completed goes from false to true.
We know how to react to an objective getting completed. Now, here's how you can trigger an objective completion from scripts:
game.quests.getQuest("myQuest").completeObjective("myObjective")
We know how to complete a quest objective from another script. But sometimes, it's not most convenient way to monitor quest objectives. Quest scripts can also implement watchers, to be warned when some events happen in the game.
This watcher gets triggered whenever the player picks an item up. Here's an example:
class MyQuest {
...
onItemPicked(item) {
if (item.itemType == "someQuestItem") {
this.model.setVariable("itemPicked", 1);
this.model.completed = true;
}
}
}
This watcher gets triggered whenver a character gets killed, including if the killer wasn't the player, or the character didn't die in combat. Here's an example:
class MyQuest {
...
onCharacterKilled(victim, killed) {
if (victim.race == "mutated-rat") {
const killCount = (this.model.getVariable("ratKilled") || 0) + 1;
this.model.getVariable("ratKilled", killCount);
if (killCount > 10)
this.model.completed = true;
}
}
}
Buffs are bonuses or maluses that can be added to a Character. Buffs themselves are game objects, and the scripts are located at scripts/buffs. They feature a TaskManager, allowing for functions methods to be called regularly, and eventually remove the buff once it has expired.
Buffs on a character are visible in the status panel in the character sheet view, below the Hit points display:
Adding a buff on a character is fairly easy. The name of a buff is defined by the name of it scripts. To add the buff defined in scripts/buffs/bleeding.mjs, we would do the following:
characters.addBuff("bleeding");
The bleeding buff is a tick-based buff, meaning that once it runs off of ticks, the buff will remove itself. But what if we wanted to remove it, for another reason ? We will first need to fetch the buff game object, before removing it:
const buff = characters.getBuff("bleeding");
if (buff)
buff.remove();
Now that we know how to apply or remove buffs from a character, let's see how to create our own custom buffs. Let's make a blinded buff, reducing the character's perception:
class Blinded {
constructor(model) {
this.model = model;
}
initialize() {
this.model.target.statistics.perception -= 5;
}
finalize() {
this.model.target.statistics.perception += 5;
}
}
export function create(model) {
return new Blinded(model);
}
As you can see, like other game objects, buffs have an initialize method, whcih gets executed when the buff is applied on the player. But buffs also have a finalize method, which gets called as the buff gets removed from the character.
Currently, our buff will remain on the character indefinitely, until another script manually removes it. If we want our buff to expire, we can leverage the TaskManager:
class Blinded {
constructor(model) {
this.model = model;
}
initialize() {
this.model.target.statistics.perception -= 5;
this.tasks.addTask("expire", 86400000, 1);
}
finalize() {
this.model.target.statistics.perception += 5;
}
expire() {
this.model.remove();
}
}
export function create(model) {
return new Blinded(model);
}
We now schedule a call to our expire method, one day after the buff gets applied. Then, in the expire method, we call our game object's remove method, which will in turn call the finalize method and remove the buff from the character.
There is one important fact to know about buffs: there cannot be several instance of a same buff on the same player at a given time. However, it is possible to cumulate buffs using a single buff game object. We will now see what happens when you add a buff to a character, when an instance of the same buff is already being applied on a character.
If you were to call character.addBuff("blinded") twice, it's initialize method will only be called once. However, if you provide a repeat method, it will get called instead. Let's see how this might be implemented:
repeat() {
this.tasks.removeTask("expire");
this.tasks.addTask("expire", 86400000, 1);
}
In our repeat method, we first unschedule the call to our expire method, and re-schedule it to one day in the future. This means applying the buff two times on a character doesn't change anything, but ensure that the buff won't expire until one day after the last addition, rather than one day after the first addition.