Verb orientated side pane - ThePix/QuestJS GitHub Wiki

The standard side pane in Quest is item-orientated - the user is presented with a list of objects, and can then select a verb to apply to it.

However, an argument can be made that that is the wrong way round. After all, we say EXAMINE HAT, not HAT EXAMINE. How would one go about modifying Quest 6 to be verb-orientated? We will go through it step by step, but the final code is collected together at the end, and you are free to just paste that into the appropriate files.

Two approaches are presented.

We could implement this so the player selects a verb, and a menu appears with all items that can be used with that verbs. A demo of that can be seen here.

An alternative is to have the player click on a verb that appears in the command box, and then clicks on an item in the text; the item would then appear next to the verb, and the player can click a button to submit the command. A demo of that can be seen here.

There is a summary at the end of both sections where the final code is presented if you want to just grab that.

Using a Menu

Changing Panes

In simple terms, we need to take out the existing panes, and put in our custom pane. As a starting point, I will just copy the simple example from here, to get it working. This should go into setting.js.

settings.inventoryPane = []
settings.setup = function() {
  createPaneBox(2, "Extra options", '<input type="button" value="Click me" onclick="alert(\'Here!\')" />')
}

If you are doing this for an existing game, you might already have a settings.setup function. You can only have one, so you will need to take the code from inside the function here (just one line so far) and paste it inside your existing function. Reload your game in your browser, and you should see a "Click me" button, but no inventories.

Adding Verbs

So now we need some verbs. The best way to do that is to create a list first, and then go though the list when creating the pane. It makes it easier to modify the list if we keep it separate.

settings.inventoryPane = []
settings.verbs = ['Examine', 'Take', 'Drop', 'Push', 'Talk to', 'Open']
settings.styleFile = 'style'
settings.setup = function() {
  let s = ''
  for (let el of settings.verbs) {
    s += '<p class="verb">' + el + '</p>'
  }
  createPaneBox(2, "Verbs", s)
}

The setup function here is generating an HTML string, s, from the list of verbs, using the P tag-name, which indicates a paragraph. You will see I have added a class "verb" to the HTML elements.

By adding a style.css file (third line above), we can have the verbs look attractive. Here is the contents of that file; you may want to adjust for your game. If you already have a CSS file set, you should instead add this to the end of your existing file, and not set settings.styleFile.

.verb {
  text-align: center;
  padding: 3px;
  margin: 3px 12px;
  background: silver;
  border: black 1px solid;
  border-radius: 2px;
  cursor: pointer;
}

So you should now see the verb buttons, and the cursor should change as you hover over them, but they do not do anything.

Items too

We need them to do something. This is done by adding an "onclick" attribute to the HTML elements (in line seven, two above). We want our JavaScript to produce this HTML:

Examine

It gets a bit complicated because of the nested quotes:

    s += '<p class="verb" onclick="settings.verbClick(\'' + el + '\')">' + el + '</p>'

So now we need a settings.verbClick function:

settings.verbClick = function(verb) {
  $('#sidepane-menu').remove()
  const opts = {article:DEFINITE, capital:true}
  let s = '<div id="sidepane-menu"><p class="sidepane-menu-title">' + verb + ':</p>'
  for (let el of scopeReachable()) {
    if (el === game.player) continue
    if (!el.getVerbs) continue  // presumably a room
    if (!el.getVerbs().includes(verb)) continue
    s += '<p value="' + el.name + '" onclick="settings.verbItemClick('
    s += verb + ', ' + el.name + ')" class="sidepane-menu-option">'
    s += lang.getName(el, opts)
    s += '</p>';
  }
  s += '<p onclick="settings.verbItemForget()" class="sidepane-menu-option sidepane-menu-forgetit">'
  s += 'Forget it'
  s += '</p>';
  s += '</div>'
  $('body').append(s)
}

The first line inside the function removes any existing pane. After that, it is creating a new one. As before, it is building an HTML string, s. It iterates through all the items found with scopeReachable(), skipping any without the verb in its getVerbs() array.

At the end, it adds a further option the player can click to just remove the pane without doing anything.

We need some styles to get this to work - and look nice. Append this to style.css:

#sidepane-menu {
  position: fixed;
  top: 200px;
  left: 100px;
  z-index:50;
  padding:5px;
  width:240px;
  background-color: #f9f9f9;
  border:solid #ccc 1px;
}

.sidepane-menu-title {
  font-style:italic;
  padding:5px;
}

.sidepane-menu-option {
  background-color: #faf;
  border:solid black 1px;
  cursor: pointer;
  padding:5px;
  border: black solid 1px;
  border-radius: 4px;
  background: #e1ebf2;
  margin-top: 10px;
}

.sidepane-menu-forgetit {
  background-color: #faa;
}

Make something happen!

Now we need two more functions, one for when the player clicks "Forget it" and one to actually do something. The first is trivial, it just has to remove the panel. The second just has to do that, and to pass the verb and item to the parser. We use the item's alias (via lang.getName) as this is what the parser will match against, rather than the name attribute, in case they are different.

settings.verbItemForget = function() {
  $('#sidepane-menu').remove()
}

settings.verbItemClick = function(verb, item) {
  $('#sidepane-menu').remove()
  parser.parse(verb + ' ' + lang.getName(w[item]))
}

Some Issues

No text input

If you want to disable the text prompt, add this to settings.js:

settings.textInput = false

Verbs

You will need to make sure your items have the right verbs. Quest will set some of these from the templates you give to an item. It will also add "Use" to any item with a "use" attribute. Do not give your items their own "getVerbs" function as it will mess up the existing system. Inside, use the "verbFunction", which is described here.

Talk to

Note that by default TALK TO is disabled; if you want it to work as a verb, add this:

settings.noTalkTo = false

Examine

Quest 6 assumes you look at people, rather than examine them. This means that you will not get the option to examine NPCs as it stands. An option would be to add a LOOK AT verb to our list, but that seems redundant. So, we will change our verb list to include "Look at":

settings.verbs = ['Look at', 'Take', 'Drop', 'Push', 'Talk to', 'Open']

Then change the items to use "Look at" rather than examine. This is easily done by changing the language settings. This needs to be done in a different file, as the language object does not exist when settings.js is run. You can put it in code.js, for example:

lang.verbs.examine = "Look at"

Multi-verbs!

How about combining open and close? They are mutually exclusive, and t will keep the list of verbs short. The list now looks like this:

settings.verbs = ['Look at', 'Take', 'Drop', 'Push', 'Talk to', 'Open/close']

As with did with examine, we need to change the display verbs so when a container is open or close it gives a verb that matches what we have in the list above. So we should now have this in code.js.

lang.verbs.examine = "Look at"
lang.verbs.open = "Open/close"
lang.verbs.close = "Open/close"

We also need a new command that responds to OPEN/CLOSE BOX.

commands.unshift(new Cmd('OpenOrClose', {
  regex:/^open\/close (.+)$/,
  objects:[
    {scope:parser.isPresent, attName:"open"},
  ],
  script:function(objects) {
    const o = objects[0][0]
    if (o.closed) {
      return o.open(false, game.player) ? world.SUCCESS : world.FAILURE
    }
    return o.close(false, game.player) ? world.SUCCESS : world.FAILURE
  },
}))

This is going to match open/close (note the back-slash in the regular expression to escape the slash). The scope tells Quest to consider any item present - held or here - and to prioritise items with an "open" attribute.

The script checks if the item is already opened or closed and calls the appropriate attribute. The attributes are sorted out in the standard OPENABLE and CONTAINER templates.

Summary

At the end of all that, you should have the following code in settings.js:

settings.textInput = false
settings.noTalkTo = false
settings.styleFile = 'style'
settings.inventoryPane = []
settings.verbs = ['Look at', 'Take', 'Drop', 'Push', 'Talk to', 'Open/close']
settings.setup = function() {
  let s = ''
  for (let el of settings.verbs) {
    s += '<p class="verb" onclick="settings.verbClick(\'' + el + '\')">' + el + '</p>'
  }
  createPaneBox(2, "Verbs", s)
}

settings.verbClick = function(verb) {
  $('#sidepane-menu').remove()
  const opts = {article:DEFINITE, capital:true}
  let s = '<div id="sidepane-menu"><p class="sidepane-menu-title">' + verb + ':</p>'
  for (let el of scopeReachable()) {
    if (el === game.player) continue
    if (!el.getVerbs) continue  // presumably a room
    if (!el.getVerbs().includes(verb)) continue
    s += '<p value="' + el.name + '" onclick="settings.verbItemClick(\''
    s += verb + '\', \'' + el.name + '\')" class="sidepane-menu-option">'
    s += lang.getName(el, opts)
    s += '</p>';
  }
  s += '<p onclick="settings.verbItemForget()" class="sidepane-menu-option sidepane-menu-forgetit">'
  s += 'Forget it'
  s += '</p>';
  s += '</div>'
  $('body').append(s)
}

settings.verbItemForget = function() {
  $('#sidepane-menu').remove()
}

settings.verbItemClick = function(verb, item) {
  $('#sidepane-menu').remove()
  parser.parse(verb + ' ' + lang.getName(w[item]))
}

You should have this in a different .js file, I suggest code.js:

lang.verbs.examine = "Look at"
lang.verbs.open = "Open/close"
lang.verbs.close = "Open/close"


commands.unshift(new Cmd('OpenOrClose', {
  regex:/^open\/close (.+)$/,
  objects:[
    {scope:parser.isPresent, attName:"open"},
  ],
  script:function(objects) {
    const o = objects[0][0]
    if (o.closed) {
      return o.open(false, game.player) ? world.SUCCESS : world.FAILURE
    }
    return o.close(false, game.player) ? world.SUCCESS : world.FAILURE
  },
}))

And this in your style.css file:

.verb {
  text-align: center;
  padding: 3px;
  margin: 3px 12px;
  background: silver;
  border: black 1px solid;
  border-radius: 2px;
  cursor: pointer;
}

#sidepane-menu {
  position: fixed;
  top: 200px;
  left: 100px;
  z-index:50;
  padding:5px;
  width:240px;
  background-color: #f9f9f9;
  border:solid #ccc 1px;
}

.sidepane-menu-title {
  font-style:italic;
  padding:5px;
}

.sidepane-menu-option {
  background-color: #faf;
  border:solid black 1px;
  cursor: pointer;
  padding:5px;
  border: black solid 1px;
  border-radius: 4px;
  background: #e1ebf2;
  margin-top: 10px;
}

.sidepane-menu-forgetit {
  background-color: #faa;
}

Building a Command

Changing Panes and Adding Verb

This is almost the same as before; see the first two sections above. There are two additional lines in settings.setup where we set the text input to be read only, so the player cannot type anything there; and then add "Submit" and "Clear" buttons.

settings.inventoryPane = []
settings.verbs = ['Examine', 'Take', 'Drop', 'Push', 'Talk to', 'Open']
settings.styleFile = 'style'
settings.setup = function() {
  let s = ''
  for (let el of settings.verbs) {
    s += '<p class="verb">' + el + '</p>'
  }
  createPaneBox(2, "Verbs", s)
  $('#textbox').attr('readonly', 'readonly')
  $('#textbox').after('<p style="text-align:center"><input type="button" onclick="settings.verbSubmit()" value="Submit" /><input type="button" onclick="settings.verbClear()" value="Clear" /></p>')
}

Reacting to the player

We will now need four simple functions. We need to react to the player clicking a verb, an item, the submit button and the clear button.

We will use a variable, settings.verbWaiting to track state; if it is true, the player can click an item. |If false, the settings.itemClick just returns without doing anything. Usually I would advise against storing anything that might change in settings, as it will not be saved, but this is not something I want to get saved so is fine. You will see we are adding and removing a class to the "body" element; this will be discussed later.

Besides that, settings.verbClick and settings.itemClick put text into the value of the text box, whilst settings.verbClear and settings.verbSubmit clear it, the latter first sending the text to the parser.

settings.verbClick = function(verb) {
  $('#textbox').val(verb)
  $('body').addClass("clickable")
  settings.verbWaiting = true
}

settings.itemClick = function(item) {
  if (!settings.verbWaiting) return
  const o = w[item]
  $('#textbox').val($('#textbox').val() + ' ' + lang.getName(o))
  $('body').removeClass("clickable")
}

settings.verbClear = function() {
  $('#textbox').val('')
  $('body').removeClass("clickable")
  settings.verbWaiting = false
}

settings.verbSubmit = function() {
  parser.parse($('#textbox').val())
  $('#textbox').val('')
  $('body').removeClass("clickable")
  settings.verbWaiting = false
}

You can check it is working at this point. The parser will complain it does understand, but the verb should appear in the command line.

Interact with Items

We also need to change how items are displayed so they can be clicked on. We will re-write the text processor "objects" directive, which should only be used by room templates, i.e., the bit of the room description that says what is here. The text processor bit just has to call a new function, "listForVerbs".

This new function is based on formatList, and is quite complicated, but the relevant bit is where we get a list of item names with links. The links are in the form of "span" elements with "onclick" attributes.

This should go in code.js, as it needs to happen after the library files are loaded.

tp.text_processors.objects = function(arr, params) {
  const itemArray = scopeHereListed()
  return listForVerbs(itemArray)
}

function listForVerbs(itemArray) {
  const itemArray = scopeHereListed()

  // nothing here
  if (itemArray.length === 0) return lang.list_nothing

  // set options, modify as desired
  const options = {
    sep:",",
    article:INDEFINITE,
    lastJoiner:lang.list_and, 
    modified:true, 
    loc:game.player.loc
  }
  
  // sort alphabetically
  itemArray.sort(function(a, b){
    if (a.name) a = a.name;
    if (b.name) b = b.name;
    return a.localeCompare(b)
  });
  
  // get list of item names with links
  const l = itemArray.map(el => {
    return '<span class="clickable-item" onclick="settings.itemClick(\'' + el.name + '\')">' + lang.getName(el, options) + '</span>'
  })
  
  // assembe list
  let s = "";
  if (settings.oxfordComma && l.length === 2 && options.lastJoiner) return l[0] + ' ' + options.lastJoiner + ' ' + l[1]
  do {
    s += l.shift()
    if (l.length === 1 && options.lastJoiner) {
      if (settings.oxfordComma) s += options.sep
      s += ' ' + options.lastJoiner + ' '
    } else if (l.length > 0) s += options.sep + ' '
  } while (l.length > 0);
  
  return s;
}

Make it look nice

In the style.css file, add this to make the text box look nice.

.verb {
  text-align: center;
  padding: 3px;
  margin: 3px 12px;
  background: silver;
  border: black 1px solid;
  border-radius: 2px;
  cursor: pointer;
}


#textbox {
  border: black solid 1px;
  border-radius: 2px;
  background: #eee;
  margin: 5px;
  padding: 5px 15px;
  font-size: 16pt !important;
  font-weight: bold;
}  


.clickable .clickable-item {
  background: yellow;
  cursor: pointer;
}

The last element is of note because it allows us to change the appearance on the fly. It says that any element with the class "clickable-item" that is inside another element with the class "clickable" should have a yellow background. We add the "clickable" class to the "body" element when an item is wanted, and remove it when an item is not wanted.

You should be able to go into the game and actually do stuff now.

Inventory

There is an issue here that once an item is in the player's inventory, to use it the player will need to scroll back to the last room description that included it to be able to use it - and if the screen has been cleared, she may not be able to access it at all. We need to allow items to be selected from the inventory list - and indeed, to allow the player to see the inventory.

The latter is easy, just add "Inventory" as a verb.

settings.verbs = ['Inventory', 'Look at', 'Take', 'Drop', 'Push', 'Talk to', 'Open/close']

To get the listed items to be clickable, we need to modify the "Inv" command to use the "listForVerbs" function we created earlier:

findCmd('Inv').script = function() {
  const itemArray = game.player.getContents(world.INVENTORY);
  msg(lang.inventory_prefix + " " + listForVerbs(itemArray) + ".", {char:game.player});
  return settings.lookCountsAsTurn ? world.SUCCESS : world.SUCCESS_NO_TURNSCRIPTS;
}

Summary

At the end of all that, you should have the following code in settings.js:

settings.cursor = ''
settings.styleFile = 'style'
settings.inventoryPane = []
settings.verbs = [{verb:'Inventory', noItem:true}, 'Look at', 'Take', 'Drop', 'Push', 'Talk to', 'Open/close']
settings.setup = function() {
  let s = ''
  for (let el of settings.verbs) {
    if (typeof el === 'string') {
      s += '<p class="verb" onclick="settings.verbClick(\'' + el + '\')">' + el + '</p>'
    }
    else {
      s += '<p class="verb" onclick="settings.verbOnlyClick(\'' + el.verb + '\')">' + el.verb + '</p>'
    }
  }
  createPaneBox(2, "Verbs", s)
  $('#textbox').attr('readonly', 'readonly')
  $('#textbox').after('<p style="text-align:center"><input type="button" onclick="settings.verbSubmit()" value="Submit" /><input type="button" onclick="settings.verbClear()" value="Clear" /></p>')
}

settings.verbClick = function(verb) {
  $('#textbox').val(verb)
  $('body').addClass("clickable")
  settings.verbWaiting = true
}

settings.verbOnlyClick = function(verb) {
  $('#textbox').val(verb)
}

settings.itemClick = function(item) {
  if (!settings.verbWaiting) return
  const o = w[item]
  $('#textbox').val($('#textbox').val() + ' ' + lang.getName(o))
  $('body').removeClass("clickable")
}

settings.verbClear = function() {
  $('#textbox').val('')
  $('body').removeClass("clickable")
  settings.verbWaiting = false
}

settings.verbSubmit = function() {
  parser.parse($('#textbox').val())
  $('#textbox').val('')
  $('body').removeClass("clickable")
  settings.verbWaiting = false
}

In code.js:

commands.unshift(new Cmd('OpenOrClose', {
  regex:/^open\/close (.+)$/,
  objects:[
    {scope:parser.isPresent, attName:"open"},
  ],
  script:function(objects) {
    const o = objects[0][0]
    if (o.closed) {
      return o.open(false, game.player) ? world.SUCCESS : world.FAILURE
    }
    return o.close(false, game.player) ? world.SUCCESS : world.FAILURE
  },
}))

tp.text_processors.objects = function(arr, params) {
  const itemArray = scopeHereListed()
  return listForVerbs(itemArray)
}

findCmd('Inv').script = function() {
  const itemArray = game.player.getContents(world.INVENTORY);
  msg(lang.inventory_prefix + " " + listForVerbs(itemArray) + ".", {char:game.player});
  return settings.lookCountsAsTurn ? world.SUCCESS : world.SUCCESS_NO_TURNSCRIPTS;
}

function listForVerbs(itemArray) {
  if (itemArray.length === 0) return lang.list_nothing

  const options = {
    sep:",",
    article:INDEFINITE,
    lastJoiner:lang.list_and, 
    modified:true, 
    loc:game.player.loc
  }
  
  itemArray.sort(function(a, b){
    if (a.name) a = a.name;
    if (b.name) b = b.name;
    return a.localeCompare(b)
  });
  
  const l = itemArray.map(el => {
    return '<span class="clickable-item" onclick="settings.itemClick(\'' + el.name + '\')">' + lang.getName(el, options) + '</span>'
  })
  
  let s = "";
  if (settings.oxfordComma && l.length === 2 && options.lastJoiner) return l[0] + ' ' + options.lastJoiner + ' ' + l[1]
  do {
    s += l.shift()
    if (l.length === 1 && options.lastJoiner) {
      if (settings.oxfordComma) s += options.sep
      s += ' ' + options.lastJoiner + ' '
    } else if (l.length > 0) s += options.sep + ' '
  } while (l.length > 0);
  
  return s;
}

And in style.css:

.verb {
  text-align: center;
  padding: 3px;
  margin: 3px 12px;
  background: silver;
  border: black 1px solid;
  border-radius: 2px;
  cursor: pointer;
}

#textbox {
  border: black solid 1px;
  border-radius: 2px;
  background: #eee;
  margin: 5px;
  padding: 5px 15px;
  font-size: 16pt !important;
  font-weight: bold;
}  

.clickable .clickable-item {
  background: yellow;
  cursor: pointer;
}
⚠️ **GitHub.com Fallback** ⚠️