Node based Maps - ThePix/QuestJS GitHub Wiki

The Quest 6 node-based map system is designed to be as easy as possible, whilst still allowing authors as much flexibility as possible. It uses SVG, and there is a quick guide to SVG at the end of this page, though you do not need to know any SVG to use most of the features.

quest-map

Adding The Quest Map

By default, the map library is not included in a Quest 6 game (though the file is present). The first step, then, is to tell Quest to include this library. To do that, add this line to your settings.js file (and indeed all options that start "settings" should go in that file):

settings.libraries.push('node-map')

That is it for simple games. The player can now use the MAP command to toggle the map, but it defaults to visible. Away you go!

Simple Games

What counts as a simple game? As far as the map is concerned a game is simple if:

  • Every location can be accessed from the room the player starts in using only the compass directions and up/down
  • Every location can be laid out in a neat grid in 3 dimensions
  • Every exit goes to the obvious neighbour in that direction in the grid (if the direction is up, the location is directly above, etc.)
  • If an exit has a returning exit, it is in the opposite direction

If you are not sure, try it and see. Quest will display warnings in the console if it is struggling (press F12, and select the console tab). If there are no errors or warnings, you are good to go (if there are some, read on, and hopefully you will find how to resolve them).

Basic Customisation

You can customise the map in various ways by setting these options in settings.js.

settings.mapShowNotVisited = true
settings.mapCellSize = 25
settings.mapScale = 30
settings.mapLocationColour = 'black'
settings.mapBorderColour = 'white'
settings.mapExitColour = 'blue'
settings.mapExitWidth = 5
settings.mapStyle = {right:'0', top:'200px', width:'300px', height:'300px', 'background-color':'#333' }

By default, a location is only displayed after it is visited. Setting the first of the above to true will have the whole map displayed from the start. This is especially useful when debugging your map.

The cell size is the default width and height of the squares used to represent a location in pixels, while the scale is the number of pixels from the centre of one to the centre of the next. You can also set various default colours.

The last is a bit more complicated as it does several settings at once, but controls the position and style of the map itself (using CSS). This is quite powerful, and you could use it to add a background image or a fancy border. The example below adds a fairly plain border, but CSS has a lot of potential here.

settings.mapStyle = {
  right:'0',
  top:'200px',
  width:'400px',
  height:'400px',
  'background-color':'#ddd',
  'background-image':'url(game-map/paper.jpg)',
  border:'3px black solid',
 }

Note that "background-color" and "background-image" must be in quotes as they include a hyphen (and CSS requires the US spelling of colour). Also note how the URL for the background is done.

More than one isolated region

If your game is split into different regions with no standard exits connecting them, the auto-mapping system will only find the rooms in the region the player starts in by default. By setting settings.mapAutomapFrom you can have it start from a number of locations. It is important that these locations cannot be accessed from each other by the compass/vertical directions - Quest will get confused if they can.

settings.mapAutomapFrom = ['lounge', 'glade']

Quest will put each map section in its own region, completely isolated from each other. If that is not what you want, you can add your own settings.mapGetStartingLocations, which should return an array of starting locations, one for each region, and gives you the opportunity to set the "mapX","mapY", "mapZ" and "mapRegion" values for each. This example sets up two locations in the same region as starting points.

settings.mapGetStartingLocations = function() {
    const start1 = w.lounge
    start1.mapX = 0
    start1.mapY = 0
    start1.mapZ = 0
    start1.mapRegion = 0
    const start2 = w.glade
    start2.mapX = 1000
    start2.mapY = 0
    start2.mapZ = 0
    start2.mapRegion = 0
    return [start1, start2]
}

This might be useful if there is some link between the two areas, but it is not one the automapper can cope with (such as IN or OUT, or even CLIMB).

Location labels

You can have labels displayed for each location by setting "settings.mapDrawLabels" to true. Labels will be horizontally centred on the location, but you can adjust the vertical position with "settings.mapLabelOffset".

The style of the text can be set in the settings.js file, for example, this gives rather small text, rotated slightly to allow for longer names.

settings.mapDrawLabels = true
settings.mapLabelStyle = {'font-size':'8pt', 'font-weight':'bold'}
settings.mapLabelColour = 'blue'
settings.mapLabelOffset = -5
settings.mapLabelRotate = -20

You can set the "mapLabel" attribute of a location to a string, to have that text shown instead of its alias.

Custom marker

By default, Quest will use a blue circle to indicate the player's current position, but you can add your own settings.mapMarker function to have it display anything you want. This example uses the map.polygon helper function to draw an arrow, which is partly transparent.

settings.mapMarker = function(loc) {
  return map.polygon(loc, [
    [0,0], [-5,-25], [-7, -20], [-18, -45], [-20, -40], [-25, -42], [-10, -18], [-15, -20]
  ], 'stroke:none;fill:black;pointer-events:none;opacity:0.3')
}

Extras

You can add anything you like on top of the map by adding a custom settings.mapExtras function, which should return an array of SVG elements. This example adds the compass that can be seen in the image at the top:

settings.mapExtras = function() {
  const result = []
  const room = w[player.loc]
  result.push(map.polygon(room, [
    [150, 100],
    [147, 117],
    [130, 120],
    [147, 123],
    [150, 160],
    [153, 123],
    [170, 120],
    [153, 117],
  ], 'stroke:black;fill:yellow;'))
  result.push(map.text(room, 'N', [150, 100], 'fill:black;font-size:14pt'))
  return result
}

Customising Specific Locations

Basic

You can also set values for a specific location to modify just that one place.

mapIgnore
mapWidth
mapHeight
mapColour
mapLabel

An example in use:

createRoom("lounge", {
  desc:"The lounge is boring, the author really needs to put stuff in it.",
  west:new Exit('dining_room'),
  south:new Exit('hall'),
  mapColour:'red',
  mapWidth:45,
  mapHeight:45,
})

If "mapIgnore" is set to true, this room will not get included when Quest maps all the rooms (and therefore any rooms linked via it, which may cause issues if they do not have "mapIgnore" set to true as well). All exits to this room will also get ignored.

The others are pretty obvious.

Other Shapes

You can give a location it own "mapDrawBase" function to make it any shape. The function should return a string containing the SVG shape. The first example gives a circular room, the second uses a helper function that draws a polygon. Using the "path" SVG element, you could make it any shape you like (hexagon, star, bus...).

  mapDrawBase:function(o) {
    let s = '<circle cx="'
    s += this.mapX
    s += '" cy="'
    s += this.mapY
    s += '" r="10" stroke="red" fill="yellow"/>'
    return s
  },

  mapDrawBase:function() {
    return map.polyroom(this, [[0, 20], [20, 0], [0, -20], [-20, 0]], 'stroke:black;fill:#cfc')
  },

Multi-locations

A multi-location is a place that is sufficiently big that you represent it with more than one location in the game. A street, for example, might have a location at the south end, the middle and the north end. From an aesthetic point of view, it will look better if these locations appear to be a single place in the map.

There are two ways to do that, depending on whether you want the entire thing to appear when the player enters any of these locations. For the street example, suppose the player walks into the south end of the street. Does the map now display the entire street, or just the south end of it?

Just one part

To have it just show one part at a time, the best approach is to use the "Other Shapes" technique just described. Draw a rectangle that extends to where it meets the rest of the location, with no border. Then add a second SVG element (and perhaps more) to draw in the borders as lines or paths. This does require some understanding of SVG and coordinates!

Here is an example:

createRoom("garden_east", {
  desc:"The east end of the garden is boring, the author really needs to put stuff in it.",
  east:new Exit('kitchen'),
  west:new Exit('garden_west'),
  mapDrawBase:function(o) {
    let s = map.rectRoom(this, [
      [-25, -16],
      [41, 32],
    ], 'stroke:none;fill:#8f8')
    s += map.polyline(this, [
      [-25, -16],
      [16, -16],
      [16, 16],
      [-25, 16],
    ], 'stroke:black;fill:none')
    console.log(s)
    return s
  },
})

createRoom("garden_west", {
  desc:"The west end of the garden is boring, the author really needs to put stuff in it.",
  east:new Exit('garden_east'),
  mapDrawBase:function(o) {
    let s = map.rectRoom(this, [
      [-16, -16],
      [41, 32],
    ], 'stroke:none;fill:#8f8')
    s += map.polyline(this, [
      [25, -16],
      [-16, -16],
      [-16, 16],
      [25, 16],
    ], 'stroke:black')
    return s
  },
})

The map.polyline function draws a series of straight lines, from one set of points to the next. It therefore expects to be sent an array where each element is itself an array of two numbers representing an absolute position (these examples have four points, so draw three lines). It also requires a room; coordinates are relative to the centre of this room. You can optionally also provide SVG attributes in CSS format; here it sets the stroke colour to black.

One big whole

If you have the map drawn from the start, this is easier; you just have one location draw the entire set (note that labels will only appear as the player visits the location).

createRoom("street_middle", {
  desc:"The street is boring, the author really needs to put stuff in it.",
  west:new Exit('hall'),
  north:new Exit('street_north'),
  south:new Exit('street_south'),
  mapDrawBase:function(o) {
    let s = '<rect x="'
    s += this.mapX - 16
    s += '" y="'
    s += this.mapY - 66
    s += '" width="32" height="132" stroke="black" fill="silver"/>'
    return s
  },
})

createRoom("street_north", {
  desc:"The street_north is boring, the author really needs to put stuff in it.",
  south:new Exit('street_middle'),
  mapDrawBase:function(o) { return '' }
})

createRoom("street_south", {
  desc:"The street_south is boring, the author really needs to put stuff in it.",
  north:new Exit('street_middle'),
  mapDrawBase:function(o) { return '' }
})

If you only want a location to appear on the map when visited, and the map is arranged so there is only one of the set that can be visited first, just make sure that is the location that draws the image (so "street_middle" in the example above).

It gets more complicated if the player could potentially visit any first. Map uses the "visited" attribute of a location to decide whether to draw it or not, so the trick is to set that for the location that draws the image. Note that the "beforeFirstEnter" and "afterFirstEnter" scripts also use the "visited" attribute, so make sure the location that does the drawing does not use either of these scripts (and indeed anything that uses the "visited" attribute for the location, as it will be inaccurate).

This example updates "street_north" so the player can enter the street at that location first.

createRoom("street_north", {
  desc:"The north end of the street is also boring, the author really needs to put stuff in it.",
  west:new Exit('garden'),
  south:new Exit('street_middle'),
  mapDrawBase:function(o) { return '' },
  afterFirstEnter:function() { w.street_middle.visited++ },
})

Customising Specific Exits

Going Off-Grid

Quest assumes each room is sat neatly on a grid. If you go south from one location, the next location is directly below it and exactly one unit length (specifically settings.mapScale) down on the map. However, you are not restricted to that. You can tell Quest that a room is not in the expected location using "mapOffset" values for the exit. This example has the conservatory shifted half a unit length to the left, relative to the hall. For the reverse direction, the negative offset is required so the hall is shifted half a unit to the right (and you will get a warning in the console if that is not the case).

createRoom("hall", {
  desc:"The hall is boring, the author really needs to put stuff in it.",
  west:new Exit('kitchen'),
  south:new Exit('conservatory', {mapOffsetX:-0.5}),
})

createRoom("conservatory", {
  desc:"The conservatory is boring, the author really needs to put stuff in it.",
  north:new Exit('hall', {mapOffsetX:0.5}),
})

Note that all exits are relative, so for exits from the conservatory, it is effectively on a new grid. A location south of there will be assumed to be directly south of the conservatory, rather than the hall.

More complicated Exits

Sometimes exits are not so simple, and may take the player somewhere the auto-mapper would not expect. Let us suppose you can go west from the dining room, and that takes you to the garden, which is one grid-square south as well as one west. There are two things we need to do in the exit itself. We also need to look at the reverse exit too, and that is easy - just tell the auto-mapper to ignore it. Let us do that one first:

createRoom("garden_east", {
  desc:"The east end of the garden is boring, the author really needs to put stuff in it.",
  east:new Exit('kitchen'),
  north:new Exit('dining_room', {mapIgnore:true}),
})

The "mapIgnore" flag tells the auto-mapper to just skip this exit; that means it will also skip any beyond that. Therefore you should set the exit heading back to the starting point to be ignored so the mapper can still find locations beyond this point.

Back to the dining room, and we need to tell the auto-mapper where the correct destination is. In this case it is the Y value that is anomalous, so we set mapOffsetY to -1. If you look at the map now, that works... kind of. It has drawn an exit from the dining room to the garden, but from the centre of each, so it looks like you go SE from the dining room to get to the garden. That is not what we want.

We need to add a custom draw to get it to look right, by giving the exit a "mapDraw" function. In this example, I use map.polyline, which draws the line relative to the given room. The attributes "mapX" and "mapY" give the coordinates, and I just displace them a bit to have the exit touch the locations at the right place (in this case by 15 pixels, but it depends on how your map is set up; you might want to experiment.

createRoom("dining_room", {
  desc:"The dining room is boring, the author really needs to put stuff in it.",
  east:new Exit('lounge'),
  west:new Exit('garden_east', {mapOffsetY:-1, mapDraw:function(fromRoom, toRoom) {
    return map.polyline(fromRoom,
      [
        [-15, 0],
        [0, -15]
      ],
    )
  }}),
  south:new Exit('kitchen'),
  mapLabel:'D-Room',
})

Here is an alternative version that gives a curve.

  west:new Exit('garden_east', {mapOffsetY:-1,  mapDraw:function(fromRoom, toRoom) {
    return map.bezier(fromRoom,
      [
        [-15, 0],
        [-35, 0],
        [-35, 35]
      ],
      'fill:none'
    )
  }}),

The map.bezier function draws a Bézier curve. Like map.polyline, expects to be sent a room, and an array where each element is itself an array of two numbers representing a position, but is a little more complicated. The first pair should be the position of the start of the curve relative to the room, but the other points are then relative to that starting position. The last pair of coordinates gives the end of the curve, the middle pairs, of which they can be one or two, give the control points.

You can also provide SVG attributes too. In this case 'fill:none' is needed to prevent the curve getting filled in. Other values get inherited from the general settings for exits.

One-way exits

If your exit has no return, the map will happily cope with that. The problem is how it gets drawn. Firstly, you will want a way to indicate it is one way, and secondly only half will get drawn by default (arguably the latter is a solution to the former, but not a great solution). Your should therefore give any one-way exit its own "mapDraw" function, so it is clear.

This version of the previous example adds an arrow head to it to indicate it is one way.

  west:new Exit('garden_east', {mapOffsetY:-1,  mapDraw:function(fromRoom, toRoom) {
    let s = map.bezier(fromRoom,
      [
        [-15, 0],
        [-35, 0], 
        [-35, 25]
      ],
      'fill:none'
    )
    s += map.polygon(toRoom,
      [
        [0, -20],
        [-5, -30],
        [+5, -30]
      ],
      'stroke:none'
    )
    return s
  }}),

Advanced

Moveable locations

Some locations can move; examples include a lift or bus. If you use the TRANSIT template, this is all handled for you. The location will appear on the map in each position it can be accessed from (assuming is is access via compass directions, rather than in/out).

Otherwise, you may find it gets quite complicated - I know I did!

Quest will store all the positions of a location (that it can find), and then draw each on the map where relevant. Note that if your moveable location has a custom "mapDrawBase" function, it should accept two parameters, the current level and a dictionary with the values of mapX, mapY, etc. to use.

Responding to mouse clicks

You can have your map respond to the player clicking on it by adding a settings.mapClick function in settings.js. This example just displays the room name, but you could teleport the player to that room, or give information about it or anything you want.

settings.mapClick = function(name) {
  console.log("Map clicked: " + name)
}

For rooms with their own mapDrawBase function you will need to add some extra code for this to work. This is calling a function, map.getClickAttrs that returns a string that needs to be inserted in the SVG for the base shape.

  mapDrawBase:function(o) {
    let s = '<circle cx="'
    s += this.mapX
    s += '" cy="'
    s += this.mapY
    s += '" r="10" stroke="red" fill="yellow"' + map.getClickAttrs(this) + '/>'
    return s
  },

Note that if you go for the "One big whole" option for large rooms, mouse clicks will seem to come from the room that draws the map, whichever part the player clicks. Although rooms from other layers are displayed, they ignore mouse clicks.

Further customisations

We have already looked at giving a location it own "mapDrawBase" function. You can also give a room its own custom label by giving it a "mapDrawLabel" function.

In addition, a location can have a "mapDrawFeatures" function that you can use to add further details. These will appear above the location (i.e., drawn over the top off, not positioned higher on the screen), but under the label. This could be used to indicate the special status of the location, for example.

You can also add functions settings.mapDefs and settings.mapExtras to add anything you want. The former will appear under the rest of the map, the later will be over the map, but under the labels. Bother should return arrays of SVG strings.

Here is a fairly complex example that shows the position of three NPCs on the map (if in the same region and level), and adds a compass in the bottom right (drawn relative to the current room to keep it in the same position).

settings.mapExtras = function() {
  const result = []
  const room = w[game.player.loc]

  // track three NPCs
  for (let o of [w.Robot, w.Lara, w.Kyle]) {
    if (w[o.loc].mapZ !== room.mapZ || w[o.loc].mapRegion !== room.mapRegion) continue
    result.push(o.mapDraw())
  }

  // Add a compass
  result.push(map.polygon(room, [
    [150, 100],
    [147, 117],
    [130, 120],
    [147, 123],
    [150, 160],
    [153, 123],
    [170, 120],
    [153, 117],
  ], 'stroke:black;fill:yellow;'))
  result.push(map.text(room, 'N', [150, 100], 'fill:black;font-size:14pt'))
  return result
}

The NPCs are set up to draw their own symbols, for example:

createItem("Robot", NPC(false), {
  loc:'street_north',
  agenda:['patrol:street_middle:street_south:street_middle:street_north'],
  mapDraw:function() { return map.rectangle(w[this.loc], [[-10,-10], [10, 10]], 'fill:silver;stroke:black') }
})

All three of these functions should return SVG code in a string. By default, the SVG string for the base room and label (but not the features) is calculated once, then stored with the location. You might want the location to be draw differently as the game progresses. In that case set the location's "mapRedrawEveryTurn" to true.

Notes

Trouble-shooting

As a general rule, I suggest starting with the basics and gradually modify the locations and settings to get what you want. If you hit an error, it will be whatever you just worked on.

You can set settings.reportAllSvg to true to see any SVG code Quest is displaying in the console. How easy it is to then see what is going wrong depends on how easily you read SVG! I suggest having the map only show where the player has visited, and then, in game, navigate to where the problem is. You can then compare the SVG in the previous location - which was presumably okay - with the SVG in the new location. The error will be in the new bit, which should narrow it down to a few lines.

If settings.playMode is set to "dev", you can use the DEBUG MAP command to get a list of locations and the coordinates assigned to them by the auto-mapper.

Setting a room colour to "none" will allow you to see through it, which might help work out what the exits are doing.

Built-in SVG shortcuts

There are a number of functions that will produce the SVG for you, some of which have already been encountered. They all take a location (the drawing will be relative to the centre of the location), a series of points (as an array of arrays), and optionally some styling information in CSS format.

The map.rectRoom and map.polyroom functions produce a rectangle and a polygon respectively that supports mouse clicks.

The map.text function takes slightly different paramters; the room, then the text to display, then an array of thye points (i.e., not an array of arrays), followed by the optional styling. It will be centred on the given point.

map.polygon
map.polyline
map.polyroom
map.bezier
map.rectRoom
map.rectangle
map.text

Creating SVG Images

For complex images, I suggest using InkScape, which you can download for free, and is a far easier way to create complex shapes. It saves files in SVG format, which is just a text file, and it is pretty easy to extra the bits you want. Remove the first two lines, then the "svg" tag, with attributes (this may be around twenty lines), and also the very last line ("").

Quest is perfectly happy for you to use several shapes for a location. However, if your image is a single shape, you just need the path attribute of that shape, and you may find it easier to make that an attribute of the room. Your "mapDrawBase" function can then use that, which means you can use pretty much the same "mapDrawBase" function each time.

createRoom("bus", TRANSIT("south"), {
  desc:"The bus is boring, the author really needs to put stuff in it.",
  south:new Exit('street_north'),
  mapColour:'red',
  mapSvg:'M 129.27804,338.45161 ... 438.05715,234.53902 z',
  mapDrawBase:function(level) {
    let s = '  <path style="fill:#888" d="' + this.mapSvg + '" id="bus-path" transform="translate('
    s += (this.mapX-25) + ' ' + (this.mapY-25)
    s += ') scale(0.1 0.1)"' + (this.mapZ === level ? map.getClickAttrs(this) : '') + 'n/>'
    return s
  },
})

Note that this transforms the image, scaling and transforming it. You can adjust the values for you images.

⚠️ **GitHub.com Fallback** ⚠️