User Input - ThePix/QuestJS GitHub Wiki
Occasionally you want to ask the user a question. This could be via a menu, or as text input.
Menus and other input functions work rather differently to wait
. This is necessary because they have to react to what the input is and that is likely to impact on subsequent output text. With wait
we know in advance what is going to be displayed, all we are doing is delaying it. If a game asks for a name for the player, it is likely to use that in the next text, so we need a different system, and unfortunately it is not as straightforward.
QuestJS offers five options for menus. I would recommend picking just one for your game and sticking with it, but you can use them all in one game. Their usage is very similar; we will look at the basic option, showMenu
, in detail, then just note the differences for the others.
Use showMenu
to display a list of hyperlinks, one per menu option; the user can click on a hyperlink to select it. The function takes three parameters, the title as a string, the options as an array and the function to run when a selection is made.
showMenu('What is your favourite color?', ['Blue', 'Red', 'Yellow', 'Pink'], function(result) {
msg("You picked " + result + ".");
});
The call-back function has a single parameter, result. This is the string or object the user selected.
In the example above, each option in the array is a string, but objects can also be included. In the next example, three objects and a string are available to be chosen. The function checks the type of result
to decide how to handle it.
showMenu("What object?", [w.book, w.coin, w.Kyle, 'None of them'], function(result) {
if (typeof result === 'string') {
msg("You picked " + result + ".");
}
else {
msg("You picked " + lang.getName(result, {article:DEFINITE}) + ".");
}
})
You are not restricted to Quest objects, you can use any dictionary/object, as long as it has an "alias" attribute. This example is for a phone. In the "use" function, an array of options is created, and then passed to the menu function. Quest then runs the "script" attribute of the selected option.
Note that "properNoun" is set to true
so "The" is not prepended. If you fail to do this, Quest will not recognise the selected item and will throw an error.
createItem("phone", TAKEABLE(), {
examine:'The phone is the elite version, with a polished silver finish.',
use:function() {
const options = [
{
alias:'Phone Mike',
properNoun:true,
script:function() {
msg("You try to phone Mike, but there is no reply.")
}
},
{
alias:'Search for "delores mining"',
properNoun:true,
script:function() {
msg('You Google "delores mining" on your phone, and find an interesting article.')
}
},
{
alias:'Nothing',
properNoun:true,
script:function() {
}
},
]
showDropDown("Use your phone to..?", options, function(result) {
result.script()
})
},
loc:"me",
})
Here is a more advanced use, which separates out the options into an attribute of the object, allowing you to dynamically change the options as the game progresses. As QuestJS only saves certain types of attributes, the useOptions attribute will not be saved - and we flag as such so Quest will not even try to - and instead the active options are stored in "activeOptions"; this can be modified to control the options available, and will be saved. It is vital that the names in that array match the ones in "useOptions".
createItem("phone", TAKEABLE(), {
examine:'The phone is the elite version, with a polished silver finish.',
saveLoadExcludedAtts:['useOptions'],
useOptions:[
{
alias:'Phone Mike',
properNoun:true,
script:function() {
msg("You try to phone Mike, but there is no reply.")
}
},
{
alias:'Search for "delores mining"',
properNoun:true,
script:function() {
msg('You Google "delores mining" on your phone, and find an interesting article.')
}
},
{
alias:'Nothing',
properNoun:true,
script:function() {
}
},
],
activeOptions:['Phone Mike', 'Nothing'],
use:function() {
const options = this.activeOptions.map(el => this.useOptions.find(el2 => el2.alias === el))
showDropDown("Use your phone to..?", options, function(result) {
result.script()
})
},
loc:"me",
})
Here is an alternative approach that gives each option a "test" function; the option is shown when "test" returns true
. I prefer this approach, as it keeps responsibilities separate. Events that change the game state just focus on that, without having to worry about their impact on the phone or other parts of the game; each option here is responsible for deciding if it is applicable.
createItem("phone", TAKEABLE(), {
examine:'The phone is the elite version, with a polished silver finish.',
saveLoadExcludedAtts:['useOptions'],
useOptions:[
{
alias:'Phone Mike',
properNoun:true,
test:function() { return true },
script:function() {
msg("You try to phone Mike, but there is no reply.")
}
},
{
alias:'Search for "delores mining"',
properNoun:true,
test:function() { return player.status > 4 },
script:function() {
msg('You Google "delores mining" on your phone, and find an interesting article.')
}
},
{
alias:'Nothing',
properNoun:true,
test:function() { return true },
script:function() {
}
},
],
use:function() {
const options = this.useOptions.filter(el => el.test())
showDropDown("Use your phone to..?", options, function(result) {
result.script()
})
},
loc:"Buddy",
})
Use showMenuNumbersOnly
for hyperlinks but with numbers. The user can again click on a hyperlink, but can also press a number on the keyboard, and that option will be picked. Other key presses will be ignored.
As the options can only be selected with a single key press you are effectively limited to nine options, though Quest will not check that.
Use showMenuWithNumbers
for hyperlinks but with numbers. This version also allows the user to type the answer or a part of it, so may feel more nature for a parser based game. If the user's response cannot be matched to one of the options, the text is assumed to be a standard command, and is passed to the parser.
Unlike the other options here, showMenuWithNumbers
:
- has to be able to handle bad input, if the user types a number out of range
- can be ignored by the user
This example shows how you might deal with the former; the latter needs special code.
showMenuWithNumbers("What object?", ['book', 'coin', w.balloon, 'None of them'], function(result) {
if (result === undefined) {
msg("You picked something I really was not expecting.");
}
else if (typeof result === 'string') {
msg("You picked " + result + ".");
}
else {
log(result)
msg("You picked " + lang.getName(result, {article:DEFINITE}) + ".");
}
})
Alternatively, the showDropDown
function displays the options in a drop-down list. This may work better for long lists, but perhaps does not look as good. The user cannot type an answer, but can select using arrows on the keyboard, as well as with the mouse. Again, this works exactly the same as showMenu
.
The showMenuDiag
function will display your menu in it own panel, next to the side pane, assuming that is on the left. This takes the menu out of the text, and to my mind is best for when the user is interacting through the side pane, rather than the text input (see also here).
showMenuDiag('What is your favourite color?', ['Blue', 'Red', 'Yellow', 'Pink'], function(result) {
msg("You picked " + result + ".");
});
Because we are taking the user away from the text, it can be helpful to give a bit of explanation. The first parameter can, therefore, be a dictionary, rather than just a string, with entries "title" and "text". This example illustrates that
showMenuDiag({
title:'Colour',
text:'What is your favourite color? This has no real effect on the game but is nice background.<br/>Or maybe not...'
}, ['Blue', 'Red', 'Yellow', 'Pink'], function(result) {
msg("You picked " + result + ".");
});
Note that the user cannot ignore a menu. Quest 5 allowed the user to just do something else, and the menu would disappear. In QuestJS, if a menu is displayed, one option must be selected. You may want to give the user the option to do nothing therefore, as in an earlier example.
Note also that menus may not work properly in the "intro" or "setup" functions, and are best avoided at the start of your game. If you want to do some sort of character creation you are better served by a dialogue box, as discussed here.
Quest will guess that anything in the list of options that is not a string is a Quest object, and will use this to get the name to display:
lang.getName(object, {article:DEFINITE, capital:true, noLinks:true})
This means you may find "The" gets prepended to a name when it should not. To prevent that, set "properNoun" to true
for the object.
If you want to see what each one looks like, put this command in your game. You can then do "dialog showMenuWithNumbers" etc., to see each menu. Note that is is case sensitive.
new Cmd('DialogTest', {
npcCmd:true,
regex:/^(?:dialog) (.*)$/,
objects:[
{special:'text'},
],
script:function(objects) {
const funcName = parser.currentCommand.tmp.string.replace(/dialog /i, '')
log(funcName)
const choices = ['red', 'yellow', 'blue']
io.menuFunctions[funcName]('Pick a colour?', choices, function(result) {
msg("You picked " + result)
})
return world.SUCCESS_NO_TURNSCRIPTS
},
})
There are three functions, showYesNoMenu
, showYesNoMenuWithNumbers
and showYesNoDropDown
that are really just short-cuts to the above functions using built in arrays.
showYesNoMenu("Are you sure?", function(result) {
msg("You said " + result + ".")
})
You may also want to ask an open-ended question. QuestJS has the askText
and askDiag
functions.
The askText
function simply prints the question, then passes a function to the parser. When the user next types something, the text goes to the function, and the parser ignores it.
If the game is not set up for text input, Quest will enable text input before hand, and then disable it after.
askText("What colour?", function(result) {
msg("You picked " + result + ".");
})
The askDiag
function will show a dialog panel, with a text input, so is perhaps a better option if text input is disabled. It works exactly the same, except that further options are available. This example shows each of the four options in use.
askDiag("How many?", function(result) {
msg("You picked " + result + ".");
}, {
submit:'Go',
comment:'You can only use digits. Any other characters will be ignore - apart from an annoying flash of red.'
filter:function(key) { return key.match(/\d/) },
validator:function(s) { return s.length > 0 },
})
If there is a "submit" attribute, the user will also have a button labelled as per the option to click to submit the text. Whether the button is there or not the user can press return to submit her response.
If there is a "comment", this text will appear before the input box, and above the button.
If there is a "filter", Quest will pass each character the user types to the function. If the function returns true, the character will be allowed. In the example above a regex is used to check if the character is a digit. If it is, it will be allowed.
If there is a "validator" function, Quest will pass the whole string to it when the user presses Return of clicks the button. If it returns true, the input will be accepted, the dialog box will disappear and response function will run. Otherwise, the dialog will remain, awaiting better input. In the above example, the validator checks the length of the string is greater the zero - i.e., something has been typed.
Here is a more involved example. This might be on a mobile phone item (cell phone), to allow the player to search the internet. The internet is represented by an array of data called internetData, which is not attached to an object, so will not be saved (it is not going to change, so saving would be a waste of time and disk space). Each entry in the array is a dictionary with a "name", "text" and optional "func" attributes.
The code asks the user to type in a search term, then searches the names of each entry for a match. An exact word match gets priority over a part word. Entries earlier in the list will also get priority.
askDiag("Use your phone to look up..?", function(result) {
// Nothing typed, so do nothing
if (!result) return false
// Require at least three characters to prevent the player just guessing entries
if (result.length < 3) {
return falsemsg("You can find nothing about \"" + result + "\" on your phone; perhaps a longer search term might work better?.")
}
// Create regular expressions to search, the first needs a whole word match
const regex1 = new RegExp("\\b" + result + "\\b", 'i')
const regex2 = new RegExp(result, 'i')
// exact word match goes in result1, otherwise result2
let result1, result2
// Search the data
for (const entry of internetData) {
if (entry.name.match(regex1)) {
result1 = entry
break
}
if (!result2 && entry.name.match(regex2)) {
result2 = entry
}
}
// handle the result
if (!result1) result1 = result2
if (!result1) {
return falsemsg("You can find nothing interesting about \"" + result + "\" on your phone.")
}
msg('You look up "' + result + '" on your phone:')
// Use "lookup" CSS class to differentiate the text (set up in style.css)
msg(result1.t, {}, 'lookup')
if (result1.func) result1.func()
})
This facility allows you to offer the user a dialog panel with a number of different widgets on it. Its flexibility does mean it is not so straightforward, however. I am not sure how well it will work if you have the response function in one trigger a second one.
We will start with a simple example. First the data is set, then it is passed to io.dialog
.
const questWidgets = {
title:'Quest!',
desc:'You are on a quest. What is most important to you?',
widgets:[
{ type:'radio', title:'Prioritise', name:'priority', data:{compan:"Companions' well-being", magic:"Magical power", money:"Accumulating money"}},
],
okayScript:function(results) {
player.priority = results.priority
msg("You go on a quest prioritising: " + player.priority)
},
}
io.dialog(questWidgets)
The first two attributes in the data are obvious. The "title" is required, and will be put inside a <h3>
element, while "desc" is optional and goes inside a <p>
element. You an also include an "html" attribute which is like "desc", but does not get put in a <p>
element - Quest assumes you will do that yourself, which means you can include sub-titles, tables, etc.
The third is a list of widgets that will appear on the dialog. In this case there is just the one. Its type is "radio" so it will be presented as a list of radio buttons. It has a title, which is shown to the user, and a name, which is used internally and should be letters and digits only. The data is a dictionary of options. You can include a "checked" attribute to give a default value (the index number for lists). You can also add a "comment" to a widget to explain to the user what is going on.
The last attribute is a script that will be called when the player clicks "Okay". This will be passed a dictionary with the results. Say the user selects "Magical power", in this case the function will be passed a dictionary with a single entry:
{ priority:'magic' }
The function in the example sets an attribute on the player and gives a message. In a proper game it would clearly need to do rather more.
You can also add a "cancelScript" that is used if the user clicks the "Cancel" button. Or have a "suppressCancel" attribute set to true
to hide the cancel button altogether, and so force the user to submit a response.
Widget options:
name | result | comment |
---|---|---|
radio | string | A set of radio buttons; requires a dictionary of options |
dropdown | string | A dropdown menu; requires a dictionary of options just as radio |
auto | string | A drop-down menu will be used if the number of options is greater than settings.widgetRadioMax , or radio buttons otherwise |
dropdownPlus | string | Like dropdown, but when a selection is made, explanatory text is updated; requires an array of dictionaries where each dictionary has name, title and text attributes |
checkbox | Boolean | the "data" attribute should just be a string |
test | string | A text input |
password | string | A hidden text input (of dubious use!) |
number | number | A text input only allowing numbers with a spinner |
The "text", "number" and "password" options allow a additional "opts" attribute, and this should be a string that will be appended to the HTML tag. For example, you could use opts:'min="1" max="5"
for a "number" widget to restrict the number to between 1 and 5. That said, this will only affect the spinner, and the user can still type in other numbers. All three of these need careful validation of the input, and for that reason may be better avoided.
So here is a rather more complicated example that builds on the previous. It is assumed that there are several quests, but the same questions will be asked for them all. A general set of data is therefore created, called "questWidgets", which sets up four widgets. The "okayScript" has been rewritten so it sets all results, whatever they are - if I later decide to add a fifth widget, this function need not be altered. There is a "cancelScript" too.
const questWidgets = {
title:'Quest!',
widgets:[
{ type:'dropdownPlus', title:'Quest', name:'quest', lines:4, data:[
{name:'inheritance', title:'Get inheritance from farm', text:'Go to the badlands, search the farm, grab anything useful, get back.'},
{name:'spider', title:'Defeat the giant spider', text:'The spider lives in a tower in the foothills. Must either kill it or persuade it to leave the area.'},
]},
{ type:'checkbox', title:'Scheduling', name:'night', data:'Go at night time?'},
{ type:'number', title:'Try how many times before giving up?', name:'tries', data:'Go at night time?', opts:'min="1" max="5"'},
{ type:'auto', title:'Prioritise', name:'priority', data:{compan:"Companions' well-being", magic:"Magical power", money:"Accumulating money"}},
{ type:'auto', title:'Strategy', name:'strategy', data:{assault:"All-out assault", stealth:"Stealth (where possible)", negotiation:"Negotiation (where possible)", tactical:'Tacital', mind:'Use mind tricks'}, checked:2},
],
okayScript:function(results) {
for (const key in results) {
player['quest_' + key] = results[key]
}
msg('You start the "' + player.currentQuest.title + '" quest.')
player.currentQuest.run()
},
cancelScript:function(results) {
msg('You consider starting the "' + player.currentQuest.title + '" quest, but decide you are not quite ready yet.')
},
}