Phone A Friend - ThePix/QuestJS GitHub Wiki

How might you implement phoning an NPC in QuestJS?

A big issue here is how you envisage this playing out for the user. Let us suppose an NPC called Lara... Perhaps you want the user to be able to type PHONE LARA, and then get a greeting. What now? Can the user do ASK LARA ABOUT LITIGATION? How does the user terminate the call? Just as there are numerous ways of handling face-to-face conversations, there are just as many when the conversation is on the phone.

I am going to assume there is only one phone in the game and its name is "phone".

Simple

We will start easy. The user types PHONE LARA, and is just given a set conversation (equivalent to "simple TALK TO").

The trick with all these is to have a scope function that will pick up NPCs in the phone's contact list, so the first thing is to create that. By convention, it belongs to the parser.

parser.isContact = function(o) { return o.contact}

Then we need a command. The scope for the object uses the function we just defined, so Quest will look for any object with "contact" set to true - and then any item present, so we cannot reply on this being set (also needs extendedScope:true as we are looking beyond the normal scope).

commands.unshift(new Cmd('Phone', {
  npcCmd:true,
  regex:/^(?:telephone|phone|call|contact) (.+)$/,
  objects:[
    {scope:parser.isContact, extendedScope:true}
  ],
  script:function(objects) {
    const npc = objects[0][0]
    if (!npc.npc) return failedmsg("Why would you want to phone {nm:item:the}?", {item:npc})
    if (!npc.contact) return failedmsg("You wish you had {nms:item:the} number in your phone.", {item:npc})
    if (npc.isHere()) return failedmsg("You think about phoning {nm:item:the}, but as {pv:item:be} is standing right here, that might look a bit odd.", {item:npc})

    return npc.phone() ? world.SUCCESS : world.FAILED
  },
}))

The script itself follows the usual format. We check through a set of conditions that would disallow the action - trying to phone an inanimate object, trying to phone someone we do not have the number for, trying to phone someone stood in the room with you.

If all goes well, we call the "phone" attribute on the NPC, and return a value depending on whether that returns true or false.

Here is our NPC. She is already flagged as a contact, but you could omit that here and set it in game, once Lara gives you her number.

createItem("Lara", NPC(true), {
  loc:'kitchen',
  contact:true,
  phone:function() { 
    msg("You phone Lara.")
    return true
  },
})

The "phone" function here does not do much, but has a lot of potential; if could check or set a number of attributes to make the phone conversation change depending on game state, or indeed make the game state change.

Advanced

An advanced system is any where we maintain the line, allowing the player to continue to communicate.

What we need to do is fake the NPC being in the room while the conversation is on going - but to stop being there when the call ends. All the relevant commands use the same scoping function, parser.isNpcAndHere, and already is set up to include any NPC that is named in the player's "onPhoneTo" attribute.

We just need to update the command, to make sure player.onPhoneTo gets set. We will only set it if the "phone" function returns true. We will also abort the command if player.onPhoneTo is already set.

commands.unshift(new Cmd('Phone', {
  npcCmd:true,
  regex:/^(?:telephone|phone|call|contact) (.+)$/,
  objects:[
    {scope:parser.isContact, extendedScope:true}
  ],
  script:function(objects) {
    const npc = objects[0][0]
    if (!npc.npc) return failedmsg("Why would you want to phone {nm:item:the}?", {item:npc})
    if (!npc.phone) return failedmsg("You wish you had {nms:item:the} number in your phone.", {item:npc})
    if (npc.isHere()) return failedmsg("You think about phoning {nm:item:the}, but as {pv:item:be} is standing right here, that might look a bit odd.", {item:npc})
    if (player.onPhoneTo === npc.name) return failedmsg("You think about phoning {nm:item:the} - then remember you already are!", {item:npc})
    if (player.onPhoneTo) return failedmsg("You think about phoning {nm:item:the} - then remember you are already on the phone to {nm:other:the}!", {item:npc, other:w[player.onPhoneTo]})

    if (npc.phone()) {
      player.onPhoneTo = npc.name
      return world.SUCCESS
    }
    else {
      world.FAILED
    }
  },
}))

We also need a HANG UP command.

commands.unshift(new Cmd('HangUp', {
  npcCmd:true,
  regex:/^(?:hang up|end call)$/,
  objects:[
  ],
  script:function() {
    if (!player.onPhoneTo) return failedmsg("You are not on a call.")
    const npc = w[player.onPhoneTo]
    if (npc.phoneEnd) {
      npc.phoneEnd()
    }
    else {
      msg("You say your goodbyes to {nm:npc:the} and hang up.", {npc:npc})
    }
    delete player.onPhoneTo
    return world.SUCCESS
  },
}))

And we are done. Give your NPCs the attributes for conversations as normal, and it should work fine. You can optionally give an NPC a "phoneEnd" function for when the call is terminated, and you might want to have different responses when on the phone - just check the value of player.onPhoneTo.

A Phone Item

So far there is no actual phone item in the game. Do you need one? Well, not necessarily.

If you feel you do - and if you want it to be useable though the side pane that is a good reason to - then the simplest approach is to give it to the player at the start, and stop her doing anything with it.

createItem("phone", {
  loc:"me",
  examine: "Your phone.",
})

Actually, that will just say the phone cannot be dropped, leaving the user wondering why. Perhaps we should give a message.

createItem("phone", {
  loc:"me",
  examine: "Your phone.",
  testDropIn:function(options) { return falsemsg("You think about putting your phone in {nm:container:the}, but figure it is best to keep it to hand.", options) },
  drop:"You think about putting down your phone, but figure it is best to keep it to hand.",
  testDrop:function(options) { return falsemsg("You think about putting down your phone, but figure it is best to keep it to hand.") },
})

I have to say that this would be how I would handle it. If the phone is important, do not let the player put it down (she will just lose it; I know what she is like). However, if you insist on having a phone the player can put down, we need a few changes, because we need to check if the player has the phone and we need to handle putting the phone down whilst on a call.

We also need to handle USE, which will be a function attribute, "use", of the phone. So now we have two ways to make a call, and possibly more to hang up. At this point, we should be thinking about re-factoring, to collect identical code into one place. We will have "makeCall" and "hangUp" function attributes on the phone that do the work.

Our PHONE and HANG UP commands are now very simple:

commands.unshift(new Cmd('Phone', {
  npcCmd:true,
  regex:/^(?:telephone|phone|call|contact) (.+)$/,
  objects:[
    {scope:parser.isContact, extendedScope:true}
  ],
  script:function(objects) {
    return w.phone.makeCall(objects[0][0]) ? world.SUCCESS : world.FAILED
  },
}))

commands.unshift(new Cmd('HangUp', {
  npcCmd:true,
  regex:/^(?:hang up|end call)$/,
  objects:[
  ],
  script:function() {
    if (!player.onPhoneTo) return failedmsg("You are not on a call.")
    w.phone.hangUp()
    return world.SUCCESS
  },
}))

All the work is done by the item:

createItem("phone", TAKEABLE(), {
  loc:"me",
  examine: "Your phone.",
  testDrop:function(options) {
    if (player.onPhoneTo) w.phone.hangUp()
    return true
  },
  use:function() {
    const contacts = scopeBy(parser.isContact)
    contacts.push('Never mind.')
    showMenuDiag('Who do you want to call?', contacts, function(result) {
      if (result === 'Never mind.') return
      w.phone.makeCall(result)
    });    
  },
  makeCall:function(npc) { 
    if (w.phone.loc !== player.name) return failedmsg("You cannot phone anyone without a phone.")
    if (!npc.npc) return failedmsg("Why would you want to phone {nm:item:the}?", {item:npc})
    if (!npc.phone) return failedmsg("You wish you had {nms:item:the} number in your phone.", {item:npc})
    if (npc.isHere()) return failedmsg("You think about phoning {nm:item:the}, but as {pv:item:be} is standing right here, that might look a bit odd.", {item:npc})
    if (player.onPhoneTo === npc.name) return failedmsg("You think about phoning {nm:item:the} - then remember you already are!", {item:npc})
    if (player.onPhoneTo) return failedmsg("You think about phoning {nm:item:the} - then remember you are already on the phone to {nm:other:the}!", {item:npc, other:w[player.onPhoneTo]})

    if (npc.phone()) {
      player.onPhoneTo = npc.name
      world.update()
      return true
    }
    return false
  },
  hangUp:function() { 
    const npc = w[player.onPhoneTo]
    if (npc.phoneEnd) {
      npc.phoneEnd()
    }
    else {
      msg("You say your goodbyes to {nm:npc:the} and hang up.", {npc:npc})
    }
    delete player.onPhoneTo
  }
})

A point to note in the above is that a call is terminated before the phone is dropped. We are cheating a bit and use the test function as that is run before the action is done. We just have to remember to return true.

The "use" function gets all the NPCs that are flagged as contacts, and uses them to produce a menu. I have chosen showMenuDiag but others can be found here. The callback function does a couple of checks, uses "makeCall".

Note that we have to call world.update() to ensure the scope gets updated properly. This is because this could be done out of sequence, if the user selects someone to call from a list of contacts. The turn will end when the menu is displayed, rather than when the user makes a selection - we need the scope to be updated later.

Supporting the side pane

First we need a new inventory to show the NPC we are on the phone to. You might want to create your own array of inventories; I am just tagging this onto the existing one:

settings.inventoryPane.push(
  {name:'On Phone To', alt:'onPhoneTo', test:function(item) { return item.name === player.onPhoneTo  } }
)

For each NPC, add this function:

  verbFunction:function(list) {
    if (!this.isHere()) list.shift()
  },

This will remove "Look at" from the list of verbs if the NPC is not here.

Be aware of one limitation. If the player enters the same room as the NPC whilst on the phone, the call will continue. The NPC will appear in both the "On phone to" list and the "In the room" list - which I think is okay. However, if the user clicks one, the verbs will appear for both, which is not so good. One way around that is to stop the player getting to that room. Another is to terminate the call when the player enters the same room.

settings.afterEnter = function() {
  if (player.onPhoneTo && w[player.onPhoneTo].loc === player.loc) {
    hangUp()
  }
}

No text input

If you disable text input, there are some short cuts you can make, so while what follows can be done either way, I am going to assume no text input. We are going to turn your old brick into a smart phone! This is a lot more expensive, so you will not be allowed to drop it. This is because we will be setting the display verbs which will be much easier if the phone is always in the player inventory.

Here is our new phone - the top part of the code anyway. The "use" attribute has been renamed to "contacts", the "testDrop" function has gone, as has the TAKEABLE template, and there is a "verbFunction" function that handles the display verbs.

createItem("phone", {
  loc:"me",
  examine: "Your phone.",
  verbFunction:function(list) {
    list.pop()
    list.push(player.onPhoneTo ? 'Hang up' : 'Contacts')
    list.push("Take photo",)
    list.push("Photo gallery")
    list.push("News feed")
    list.push("Search internet")
  },
  contacts:function() {
    const contacts = scopeBy(parser.isContact)
    contacts.push('Never mind.')
    log(contacts)
    showMenuDiag('Who do you want to call?', contacts, function(result) {
      if (result === 'Never mind.') return
      w.phone.makeCall(result)
    });    
  },
  makeCall:function(npc) {
  // ...  etc.

We need some new commands. As there is no text input, the user is very limited in her options, and we can just create some quick commands to handle these new actions

const smartPhoneFunctions = ["Contacts", "Take photo", "Photo gallery", "News feed", "Search internet", "Hang up"]

for (let el of smartPhoneFunctions) {
  commands.unshift(new Cmd(el, {
    regex:new RegExp('^' + el.toLowerCase() + ' (.+)$'),
    attName:el.toLowerCase().replace(/ /g, ''),
    objects:[
      {scope:parser.isHeld},
    ],
    defmsg:"{pv:item:'be:true} not something you can do that with.",
  }))
}

Photos

How about letting the player take some photos...

We will save the photos on the phone, in an array attribute called "gallery". I am going to add some entries that were taken previously to give some atmosphere.

The "photogallery" function just prints out that array.

The "takephoto" function is not unlike the "contacts"/"use" function. We get a list of potential targets, give the user a menu to select from, then deal with the selection. In this case, if the target has a "photo" function attribute, we let it deal with it. Otherwise, we add a new entry to the gallery, using the text processor to add some variety.

  gallery:[
    "A photo of you in a fetching bonnet sat on a garden seat, taken on Easter Sunday two years ago.",
    "A kitten playing with a ball of wool. You remember the stupid cat gave you quite a scratch just after you took the photo.",
    "You and Penny, outside the Royal Whistler, queuing to get inside on New Years Eve.",
  ],
  takephoto:function() {
    const subjects = scopeHereListed()
    subjects.push('Never mind.')
    log(subjects)
    showMenuDiag('What do you want a photo of?', subjects, function(result) {
      if (result === 'Never mind.') return
      if (result.photo) {
        result.photo()
      }
      else {
        msg("You take a photo of {nm:item:the} on your phone.", {item:result})
        w.phone.gallery.push(processText("A {random:out-of-focus:crooked:cool:artistic:indifference:poor:good:frankly awful} photo of {nm:item:the}.", {item:result}))
      }
    });    
  },
  photogallery:function() {
    msg("You idly flick through the photos on your phone...")
    for (const s of this.gallery) msg(s)
    return true
  },

For items with more interesting photo opportunities, give them a "photo" attribute.

  photo:function() {
    msg("You take a photo of Lara.")
    w.phone.gallery.push(processText("A {random:nice:blurry:good:poor} photo of {nm:item:the} {random:smiling:looking cross:eating a carrot} in {nm:loc:the}.", {item:this, loc:currentLocation}))
  },

News feed

For a news feeds, we give the phone an array of news pieces. As the game progresses, increment w.phone.newsState to move onto the next bit piece of news. Here I have split each news piece into a headline, the content and some weather.

  newsState:0,
  news:[
    {
      name:'Asteroid Heading to Earth',
      content:'Scientists in Lowther Junction are saying have detected an asteroid on a collision course with earth, due to arrive in three days.',
      weather:'The outlook for the next two days is generally fine with scattered showers, but on Tuesday expect high winds, dust storms and the end of the human race.',
    },  
    {
      name:'Asteroid Panic',
      content:'News of the impending end of the human race has led to wide-spread panic across the globe.',
      weather:'The outlook for the next two days is generally fine but with heavy showers, but on Tuesday expect high winds, dust storms and the end of the human race.',
    },  
    {
      name:'All a Big Joke!',
      content:'Scientists in Lowther Junction have now admitted that their reports about an asteroid heading for earth were just a joke.',
      weather:'The outlook for the week is generally fine with scattered showers, getting steadily heavier towards the end of the week.',
    },  
  ],
  newsfeed:function() {
    msg("You check the news on your phone...")
    const news = this.news[this.newsState]
    msg("{b:" + news.name + ":} " + news.content)
    msg("{b:Weather:} " + news.weather)
    return true
  },

Search the internet

See here.

Supporting save

We want to flag some attributes to tell Quest not to save them - and more importantly not to delete them when loading.

  saveLoadExcludedAtts:['internet', 'news'],