Quests
Ranvier's quest system is left intentionally generic. However, this makes it incredibly powerful and extensible. In this guide we will:
- Create a new quest goal type called FetchGoal (player needs to retrieve an item)
- Create a new reward type called ExperienceReward to reward the player experience for completion
- Create a couple fetch quests
- Create a quest giver for one of the quests
- Create a room script that will give the player a quest when walking into the room
info Ranvier's bundle-example-quests bundle includes example goals for fetching/equipping items and killing npcs. It also includes reward types for experience (as outlined in this guide) and currency
What is a Quest, exactly?¶
In Ranvier quests are defined as completable goals whose progress is saved. The core code makes no assumptions and has
no opinions on what those goals are, how the player progresses towards that goal, or the reward they get when they reach
it. That's up to you. To accomplish this all active quests receive all the same events that a player receives. As an
example, if a player picks up an item the get
event will fire on the player. Any active quests will hear about this
get
event at the same time and do as they please with that information.
In order to create any quests in Ranvier you must define the types of goals and rewards. These goal and reward types,
once created, can be reused in the quests you create regardless of the area they are in. For example, you may have many
quests that require you to find some object so they would all use the FetchGoal
but with differing configuration.
Likewise you may have many quests which reward experience, as such you would reuse the ExperienceReward
with differing
configuration for each one.
This setup allows you to have programmers build the goal and reward types and the builders simply specify which type to use and a configuration for that type when creating the quest with no coding needed.
Creating a new goal type¶
To have a quest that actually does something you will first need to create a class which extends the core QuestGoal
class. Once you've created your custom goal type you can have as many quests as you like which use that goal, you
don't need to create a custom goal for each individual quest. But you will need one for each different type of quest,
e.g., a goal for kill quests, a goal for fetch quests, etc.
We'll define a fetch goal as one that
- requires the player to pick up a certain number of a certain item
- optionally removes the items from the players inventory on completion
First we will create a new bundle called my-quests
, we'll use this bundle as a library for all of our quest types.
And we'll create a file under this bundle in quest-goals/FetchGoal.js
. So you should have a directory structure that
looks like this:
bundles/ my-quests/ quest-goals/ FetchGoal.js
'use strict'; const { QuestGoal } = require('ranvier'); /** * A quest goal requiring the player picks up a certain number of a particular item */ module.exports = class FetchGoal extends QuestGoal { // Quest goal constructor takes the quest it's attached to, a configuration of // this particular goal, and the player the quest is active on constructor(quest, config, player) { // Here we'll have our custom config extend some default properties: removeItem, target item and count config = Object.assign({ title: 'Retrieve Item', removeItem: false, count: 1, item: null }, config); // Call parent QuestGoal constructor super(quest, config, player); /* All quests have a "state"; this is the part that contains any data that is relevant to the current progress of the quest. So in the constructor we will set the initial progress of this to indicate that the player hasn't picked up any of the target item yet */ this.state = { count: 0 }; // Setup listeners for the events we want to update this quest's progress this.on('get', this._getItem); this.on('drop', this._dropItem); this.on('decay', this._dropItem); } /* Because Quest has no opinions and makes no assumptions, it requires you to tell it how to get the current progress of this type of goal based on its state and configuration. In our FetchGoal, progress is defined as how many items have they picked up out of how many they need to pick up in total. getProgress() should return an object like so: { percent: <number> 0-100 completion percentage, display: <string> What the user should see when the progress updates } */ getProgress() { const percent = (this.state.count / this.config.count) * 100; const display = `${this.config.title}: [${this.state.count}/${this.config.count}]`; return { percent, display }; } /* What should happen when the player completes the quest (or the game tries to complete it for the player automatically) */ complete() { // Sanity check to make sure it doesn't actually complete before it's supposed to if (this.state.count < this.config.count) { return; } const player = this.quest.player; // Here, we implement our removeItem config. // If removeItem is true, we remove all copies of the item from the player's inventory // once the quest is complete. if (this.config.removeItem) { for (let i = 0; i < this.config.count; i++) { for (const [, item] of player.inventory) { if (item.entityReference === this.config.item) { // Use the ItemManager to completely remove the item from the game this.quest.GameState.ItemManager.remove(item); } } } } super.complete(); } /* What should happen when the player picked up any item */ _getItem(item) { // Make sure the item they picked up is the item the quest wants if (item.entityReference !== this.config.item) { return; } // update our state to say they progressed towards the goal this.state.count = (this.state.count || 0) + 1; // don't notify the player of further progress if it's already ready to turn in if (this.state.count > this.config.count) { return; } // notify the player of their updated progress this.emit('progress', this.getProgress()); } /* If the player drops one of the requested items make sure to subtract that from their current progress */ _dropItem(item) { if (!this.state.count || item.entityReference !== this.config.item) { return; } this.state.count--; // Again, don't notify the player of change in progress unless they can no longer // turn in the quest if (this.state.count >= this.config.count) { return; } this.emit('progress', this.getProgress()); } };
Creating a new reward type¶
Generally players complete quests for some reward so let's create the ExperienceReward
reward type. This, as you would
imagine, will give the player some amount of experience upon completing a quest. All reward types go in the
quest-rewards/
directory of a bundle. In our case we should have a directory structure that looks like this:
bundles/ my-quests/ quest-rewards/ ExperienceReward.js
Rewards also follow the srcPath
closure structure.
'use strict'; const { QuestReward } = require('ranvier'); /** * Quest reward that gives experience * * Config options: * amount: number, default: 0, static amount of experience to give */ module.exports = class ExperienceReward extends QuestReward { /* IMPORTANT: Reward classes are used statically */ /** * The reward method is called when the player has completed the quest and * we want to actually assign the reward. * * The method accepts the GameState object allowing you to access the other * factories/managers for doing things like creating effects/accessing * areas/etc., the quest instance, the configuration of the reward * as defined by the builder, and the player to receive the reward. */ static reward(GameState, quest, config, player) { // This is a very simple reward in that it emits an event that will // be handled elsewhere for actually incrementing the player's experience, // leveling them up, etc. player.emit('experience', config.amount); // However, you are free to do as you wish in here } /** * the display() method is given the same parameters as reward() and is not * directly used by the core but you may use it in your commands to display * the rewards to the player. For example the `bundle-example-quests` bundle * calls this when the player looks at their quest log. * * The method returns a string, that's it. */ static display(GameState, quest, config, player) { return `Experience: <b>${config.amount}</b>`; } };
Reward ideas¶
Some other ideas for reward types could be CurrencyReward
, or SkillReward
. A
non-traditional reward might be something like FollowupReward
which assigns
another quest upon completing the previous which could be used to implement a
quest chain. Think outside the box and use your imagination for coming up with
interesting quest reward types for your builders. Be sure to make your reward
types generic enough that they can be configured as-needed by your builders.
Creating Quests¶
Now that we have a goal type and a reward type our builder is free to create
their quest. Creating a quest is very similar to creating items, npcs, or rooms.
Inside your area folder, next to your items.yml
, and rooms.yml
you will have
a quests.yml
. The area need not be in the same bundle as the goal and reward
types. In our case we will have a directory structure that looks like this:
bundles/ my-areas/ areas/ some-area/ items.yml npcs.yml ... quests.yml <-- this is where we will put our quests for this area
The structure of the quests.yml
file is very similar to the items.yml
file, i.e., it is a YAML array of quest definitions
--- - # Since this quest is id: journeybegins and is in area "some-area" its EntityReference will # be "some-area:journeybegins" id: journeybegins # Quests have an optional level you can use for whatever you wish, the core # does not use this for anything level: 1 # if autoComplete is true the quest will trigger the complete event as soon # all of its goals are fulfilled. Otherwise you will need a command such as # the `quest complete` command (defined in the `bundle-example-quests` bundle) autoComplete: true # Description and title are not used by the core but should be used by your # commands (such as `quest log` in the `bundle-example-quests` bundle) to display # information to the user. title, description, and completionMessage may all # contain color tags. title: "A Journey Begins" description: |- - Use 'get sword chest' to get your first weapon # There is also an optional completionMessage which, again, isn't used by the # core but you can use it to display additional content after the quest is # complete completionMessage: |- The rat looks like it is hungry, use '<white>quest list rat</white>' to see what aid you can offer. Use '<white>quest start rat 1</white>' to accept their task. # Here is where we get to use the goals we defined above goals: - # Quests may have multiple goals, each entry has a type which is the # filename of the goal type we defined but with the `.js` removed. type: FetchGoal # Here is the configuration for the goal. Aside from `title` every goal # will have its own configuration options so consult the goal type for its # available options. config: title: Find a Weapon count: 1 item: 'limbo:rustysword' # Rewards are defined exactly like goals and you may have multiple rewards # as well. rewards: - type: ExperienceReward config: amount: 5 leveledTo: quest # Here is another example quest. This one, however, is repeatable - id: onecheeseplease title: "One Cheese Please" level: 1, description: |- A rat has tasked you with finding it some cheese, better get to it. repeatable: true, goals: - type: FetchGoal config: title: Found Cheese count: 1 item: "limbo:sliceofcheese" removeItem: true rewards: - type: ExperienceReward config: amount: 100
In addition to the above, Quest definitions can also include a requires
property, which is a list
of quest EntityReferences ("limbo:journeybegins"
) which the player must complete as prerequisites before they can
start that quest. This allows you to create an entire questline or branching multi-part quests.
The quest system at this time does not have the ability to do sequential goals, i.e., a quest wherein the next goal does not appear and may not be completed until the previous goal is finished. This is currently planned but for the time being a quest with multiple goals will allow the goals to be completed in any order.
Giving the player a quest¶
Ranvier's core engine does not define when or how the player gets assigned quests so we'll go over two examples of how you may want to do it.
Questors¶
The easiest approach is to make an NPC a quest giver (questor). This functionality is not a feature of the core engine
itself but rather of the quest
command in the bundle-example-quests
bundle. If you would like to modify the base
functionality of how questors work, see the commands directory of that bundle.
The base functionality of questors also includes updates to the look
command in bundle-example-commands
which places
small progress indicators next to the NPC's name when the player sees the NPC in the room. For example:
[!] [NPC] Rat <-- The NPC has a quest available for the player [%] [NPC] Rat <-- The player has a quest in progress given by this NPC [?] [NPC] Rat <-- The player has a quest given by this NPC ready to be completed
To make an NPC a questor simply add a quests
array to their definition in npcs.yml
like so:
- id: rat keywords: ['rat'] name: 'Rat' level: 2 description: "The rat's beady red eyes dart frantically, its mouth foaming as it scampers about." quests: ['limbo:onecheesepleased']
The quests array is a list of quest EntityReferences, i.e., <area the quests.js file is in>:<quest id>
.
Now when the NPC is loaded into the game the player can access the NPC's list of available
quests with quest list rat
Scripts¶
While NPC quest givers are the easiest approach, they are not the most flexible. For example, if you wanted to give a player a quest upon logging into the game, or when they entered a certain room, or picked up a certain item. For this, you will need to harness the power of entity scripting. You can see more detailed documentation on scripting in Scripting.
In this example, we will implement giving the player a quest (The "Journey Begins" quest from above) when they enter a room.
Here we have the definition of room Test Room 1 from rooms.yml
and we'll attach the
script testroom1
to the room.
- id: testroom1 title: "Test Room 1" script: "testroom1" ...
Now we need to create the scripts file, testroom1.js
, in the room folder of scripts. So your
bundles folder should now look like this:
... my-bundle/ areas/ limbo/ scripts/ rooms/ testroom1.js manifest.yml items.yml npcs.yml quests.js rooms.yml
Now, to create the testroom1
script (again, more detail about the structure and
implementation of scripts can be found in the Scripting section):
'use strict'; module.exports = { listeners: { // Set up a listener for when a player enters the room playerEnter: state => function (player) { // entityReference of the quest we want to start const questRef = 'limbo:journeybegins'; // first check if the player can start the quest based on the quest's // prerequisites, if it's already progress, or if it's completed and not // repeatable if (state.QuestFactory.canStart(player, questRef) { // Create an instance of the Quest const quest = state.QuestFactory.create(state, questRef, player); // tell the player's quest tracker to start the quest player.questTracker.start(quest); } } } };
Now, when the player enters Test Room 1
they will be given the quest Journey Begins
(assuming
they don't already have the quest activated or completed).
Displaying progress/completion¶
Quests expose four player events that you can listen for to show a player's progress. For example the
bundle-example-quests
bundle registers some basic handlers by default to show WoW-like progress notifications.
bundles/ ranvier-quests/ player-events.js
'use strict'; const { Broadcast: B } = require('ranvier'); module.exports = { listeners: { /** * When the player begins a quest * @param {Quest} quest */ questStart: state => function (quest) { B.sayAt(this, `\r\n<bold><yellow>Quest Started: ${quest.config.title}!</yellow></bold>`); if (quest.config.desc) { B.sayAt(this, B.line(80)); B.sayAt(this, `<bold><yellow>${quest.config.desc}</yellow></bold>`, 80); } }, /** * When any quest updates its progress * @param {Quest} quest * @param {object} progress See QuestGoal.getProgress for object format */ questProgress: state => function (quest, progress) { B.sayAt(this, `\r\n<bold><yellow>${progress.display}</yellow></bold>`); }, /** * When a non-autoComplete quest has 100% progress across all of its goals * @param {Quest} quest */ questTurnInReady: state => function (quest) { B.sayAt(this, `<bold><yellow>${quest.config.title} ready to turn in!</yellow></bold>`); }, /** * Fired when a quest is completed, automatically or explicitly by a player command * @param {Quest} quest */ questComplete: state => function (quest) { B.sayAt(this, `<bold><yellow>Quest Complete: ${quest.config.title}!</yellow></bold>`); } } };