Map Visualization

The map visualization web component

Web Component build using Vue.js to visualize a map of the microservice dungeon

How to use it

Prerequisites

  • Have a recent NodeJs version installed on your computer
  • Have access to a GitLab account that is part of the “The Microservice Dungeon” group
    (If you are reading this as member of a GP or other TH project that centers around the MSD you probably have one)

Installation

1. Register the private repository with npm

  1. Generate a new Gitlab Access token with only api rights. Replace GITLAB_ACCESS_TOKEN in the below commands with it.

  2. Set this Access token in your .npmrc using the command matching to your system below:
    Unix (Linux / MacOS):

    echo "//gitlab.com/api/v4/packages/npm/:_authToken=GITLAB_ACCESS_TOKEN" >> ~/.npmrc
    

    Windows (Powershell):

    echo //gitlab.com/api/v4/packages/npm/:_authToken=GITLAB_ACCESS_TOKEN >> $env:USERPROFILE/.npmrc
    
  3. Register the @microservice-dungeon package repository:

    npm config set @the-microservice-dungeon:registry=https://gitlab.com/api/v4/packages/npm/
    

After these steps your .npmrc should look something like this:

//gitlab.com/api/v4/packages/npm/:_authToken=glpat-********************
@the-microservice-dungeon:registry=https://gitlab.com/api/v4/packages/npm/

Other options like e.g. prefix=**** are entirely possible and ok. Just make sure that these 2 lines are in this order and your auth token was correctly filled in.

2. Install the package using npm

Switch to the root directory of the project where you want to use this component and install it using npm:

npm install @the-microservice-dungeon/map-visualization

3. Import and use the component

Import the component in your project using one of the methods below. After you have imported the component you can use it as if it was a normal html component (e.g. img) anywhere in the DOM.

  • The recommended ES6 import way:

    import "@the-microservice-dungeon/map-visualization"
    
  • Using Node.Js require

    require("@the-microservice-dungeon/map-visualization")
    

    Note: This is not recommended as ES6 import statements are standardized and require is only available in Node.Js projects and based on the CommonJs module system that is slowly being replaced by ES6 modules.

Simple import examples:
Inside a Vue.js component
<template>
  <div id="app">
    <map-visualization/>
  </div>
</template>

<script>
import "@the-microservice-dungeon/map-visualization"

export default {
  name: 'App',
}
</script>
Inside a React component
import '@the-microservice-dungeon/map-visualization'

function MapComponent() {
    return (
        <map-visualization/>
    );
}

export default MapComponent;

Usage

Once you use this element in the DOM you have to give it some data to render the map you want to visualize. This web component exposes 4 simple props (attributes) that work just like e.g. the src or alt attributes of the native <img> tag. I will call them “props” going forward as this is the way they are defined in the Vue component this web component is build from.
Each web framework where you could use this component handles passing data to these props a little differently, but since this is a very basic html feature, your preferred framework should provide a way to reactively supply data to a DOM element. In this guide I will be focusing on Vue.js and React.js.

Usage examples

These examples contain functions that generate random maps of size 20 with a completely filled gravity layer and random planet/robot layers. For an explanation on what that means please see the Docs below!

Documentation and explanation of the web component

The Props

There are 4 props that get exposed:

  • map-size
  • gravity-layer
  • planet-layer
  • robot-layer

There are 3 “layer” props and the map-size. The “layer” props all work similarly and contain the actual map data. But for the map visualization to work correctly the map-size is arguably more important and is always required for the map to render anything. The layer props are technically optional, but you would want at least one to see anything. To render a complete map state all 4 props are required.

Props API

Name Type Values Description
map-size Integer The size of one side of the map
gravity-layer Array<Integer> -1, 0, 1, 2 Contains information about the “gravity” of planets / if there are any on this tile
planet-layer Array<Integer> -1, 3, 4, 5, 6, 7, 8 Contains information about the “type” of the planet on a given tile
robot-layer Array<Integer> -1, 10 Contains information on if there is a robot on the planet on a given tile

Layer array values:

Value Used by Meaning
-1 gravity-, planet-, and robot-layer This tile is empty/not relevant for this layer
0 gravity-layer The gravity level of this planet is 1
1 gravity-layer The gravity level of this planet is 2
2 gravity-layer The gravity level of this planet is 3
3 planet-layer This planet is a space station
4 planet-layer This planet contains coal
5 planet-layer This planet contains iron
6 planet-layer This planet contains ruby
7 planet-layer This planet contains gold
8 planet-layer This planet contains platin
9 ? No formal use yet. It renders a red box around the player. Could be used to indicate if a fight is happening on this tile
10 robot-layer This planet contains robots

These values correspond directly to sprites from this image: mapTiles
(Found here: /src/assets/mapTiles.png)

The map-size

A “map” of the MSD dungeon a square grid of tiles and the size of that grid is defined by this number. This number also (indirectly) dictates the length or size of the “layer” arrays.

The “layer” arrays

A layer array contains some numbers which describe one “layer” of the map we are trying to visualize. Each number describes a “tile” of the map from the top left to the bottom right and thus, each layer array has to contain a number for each tile. To ensure we always have enough space to render the entirety of the map the number of entries of each layer array has to be calculated like this:

layerArraySize = (mapSize * 2)^2
(This "size doubling" is subject to change and will probably be removed in a future version of the map visualization component!)

It is an artifact of the fact that this map was first used in a custom player that did not know the entire map and most crucially didn’t know the right coordinates for the first planet it found. Thus we needed to double the map size in order to guarantee that if the first robot spawned relatively in the middle of the map, we would still have enough space around that to render everything once it got explored.

Each layer array works like this:
If we have a map-size of e.g. 2 (So a square with 2 tiles along each side and 4 tiles total) the tiles of the map can be described like this:

1 2
3 4

To build layer arrays for this map we first need to find out the desired length of our layer arrays:

(2 * 2)^2 = 16

To understand how each number in the array relates to a tile that will be rendered consider this example:
The numbers in this “layer” array (not representative of a real layer array): [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16] will create the following map grid:

 1   2   3   4
 5   6   7   8
 9   10  11  12
 13  14  15  16

Now let’s consider the following map with only normal planets and no robots:
(For this simple example, please ignore the “doubling” of the map area as described at the start of this section)
Gravity map example

The blueish-purple tiles are tiles that where not rendered (they are transparent and show the background of the map visualization, in this case a blueish-purple).
The other colored tiles directly correspond to the “gravity” colors used by the map team in their visualizations.

This state can be described for our gravity array as such:

  -1  1  1  2
   2  2  2  2
   1  1  1 -1
   0  0 -1 -1

We can see that tiles with a “-1” are not rendered and for the other tiles we use the fitting sprite.
If we want to describe this map with a gravity array, we just inline all the numbers from the top left to the bottom right like this: [-1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1, -1, 0, 0, -1, -1] You would pass this array directly to the gravity-layer prop of the element and combined with a fitting map-size (2) it will render the map exactly like in the image above.

Generating data for the 3 layer arrays

As this component relies purely on data that describes the map state, so this is the way we have to control what the map shows. In this section I will describe a possible method on how to structure the data of the map state to ultimately create the required layer arrays that can be used to visualize it.
This method stems from the player “Robocop".It is important to know that this player had to explore the map and thus some choices that dictate the data structure where build around this fact.
The explanation will mostly focus on the concept and data structure of this method as actual implementation details are tied to the “Robocop” player

Representing the map state

To represent the map we use a list of Positions which are all uniquely defined by their x and y coordinates which position them on the “tile grid”. (This tile grid only exist in concept and the actual data structure is a List). For ease of future calculations a Position also holds its positionIndex which is just the place this Position is stored in the List. Each Position also holds two optional attributes. One references a Planet and the other a Robot. This way we have all the data we need to define any possible map state of the MSD.
To determine the length of this list to fit every tile (the amount of possible Planets) we use the same logic as before: $ListSize = (mapSize * 2)^2$. Upon initialization of the map, each entry in the list gets initialized with an empty Position that only contains its x, y and positionIndex .
When new Planets are discovered we find its neighbours, calculate the resulting x and y positions, find the corresponding Position in the list and reference the planet in it. The same process works with moving a robot. First, get the current Position of the robot and remove the referenced Robot. Then find the position of the planet the robot moved to and reference the robot there. When working with multiple robots, you should consider using a List to reference Robots on a given position as there can be multiple on one.

As you can see, mapSize is integral to the proper functionality of the map. Both for the visualization component and for representing the state in the backend. Currently, these values are hardcoded in the map service and are dependent on the player count (the number of players registered and in the current game). This might change in the future, but at the moment it can be calculated like to:

if (numberOfPlayers < 10) {
    mapSize = 15;
} else if (numberOfPlayers < 20) {
    mapSize = 20;
} else {
    mapSize = 35;
}

Generating the layer arrays from this data

Now that we have a list of Positions that tell us what’s on a specific tile we can relatively simply create our layer arrays. We just have to traverse the list and while doing so fill our 3 different arrays with the numbers that correspond with the state of the current tile. How you do this is mostly left to you, just use the correct numbers.
Example:
The first Position you get is located in the top left (x = 0, y = 0, positionIndex = 0, referencingPlanet = null, referencingRobot = null). This would result in a -1 in all of your layer arrays as there is no planet (-1 in gravity and planet layers) and no robot (-1 in the robot layer).
The next Position might look like this: (x = 0, y = 1, positionIndex = 0, referencingPlanet = {SomePlanet}, referencingRobot = null). Now we’ve got a planet, so we will need to take a look at its properties. In this example our planet has nothing. It isn’t a space station, and it does not contain resources. But it has a movementDifficulty or “gravity” of 1. This results in the following entries for each of the arrays:

layer value reason
gravityLayer 0 0 represents a movement difficulty of 1 (See gravity layer )
planetLayer -1 Nothing is on this planet
robotLayer -1 There is no robot on this planet.

Now our third Position might look like this: (x = 0, y = 2, positionIndex = 0, referencingPlanet = {SomeOtherPlanet},referencingRobot = {ARobot}). Again, lets first look at the planet. This time it has a movementDifficulty of 2 and contains some coal. We can also see that there is a Robot on this position. Let’s look at the values this produces:

layer value reason
gravityLayer 1 1 represents a movement difficulty of 2 (See gravity layer)
planetLayer 4 There is coal on this planet (See planet layer)
robotLayer 10 There is a robot present on this planet (See robot layer)

In total our 3 layers for these 3 Positions would look like this:
Gravity layer: [-1, 0, 1]
Planet layer: [-1, -1, 4]
Robot layer: [-1, -1, 10]

If we loop through the entire list we will get our 3 layer arrays that perfectly represent the current state of our map.
Now we just need to provide an endpoint that our frontend can call where we provide it with these arrays and the mapSize. Once we fetched that data we just need to pass it to the visualization component, and we can see it!

A tip on keeping the frontend up to date with your backend

Consider using a websocket from your backend to your frontend to notify it when the map has changed. Once the frontend receives this event it should query the above-mentioned endpoint to fetch the new data and pass it to the visualization component.

Documentation of the Vue component from which the web component is build

Concept

Vue.js is a powerful frontend framework that is based on the component model. This map visualization gets build from a single Vue component, a so called “sfc” (single file component). Using the vue cli we can compile this vue component into a native custom web component as it provides a simple build target exactly for that purpose.

Note: Unfortunately we have to use Vue 2 for this as Vue 3 does not support this build target yet.
This is rather unfortunate because the new composition api
would have made this code a lot cleaner and more structured.

Technical Explanations

This section contains explanations on how the code works. This is only relevant for someone who wants to develop on this component and e.g. implement fixes or new features. (Or of course someone who is just interested)
If you are just using this component this won’t contain much helpful information for you as this describes the inner workings and nothing that is relevant for you from outside.

Note: This section is best read with the code open on the side!

Possible references to code in this section will look like this:

Reference Type Reference Example
Function [File@Function] [MapVisualization.vue@initializeMap] points to this line
Line of Code [File:LineNumber] [MapVisualization.vue:164] also points to this line

Sometimes the reference to the code location will be hidden behind a hoverable text to improve readability.

Project structure

This project is a clean Vue 2 (vue-cli) project. The main component lives in /src/components/MapVisualization.vue. It gets build into a web component by using the target option of vue-cli by running npm run build.
When serving the web component with npm run serve it gets mounted into /src/App.vue which provides some helper functions to make it “work”. I.e: App can generate random maps and provides a wrapper where this element can be easily checked out.

Used dependencies (except vue)

  • planar-range: This powers the “minimap style” position picker on the top left

Technical concept

We create a canvas that renders tiles in a grid, much like described in this series of mdn tutorials which also served as the baseline and reference for implementing this component in the first place:

Initialization Steps

The initialization consists of a few steps that are executed using a promise chain. This is done to ensure all initialization steps that rely on previous steps that might require fetching of data or a DOM reload, such as fetching the tilemap or initializing the canvas don’t break.
These are the steps that are run in the [MapVisualization.vue@initializeMap] function:

  1. Load the tile sprites from the image file using [MapVisualization.vue@loadTiles]
  2. Setup / calculate some values we need for rendering with [MapVisualization.vue@setMapDimensions]
    • The amount of rows and cols based on the mapSize
    • The dimensions in pixels of the canvas
  3. Now we set this.renderMap to true which causes Vue to render the canvas and the other elements to the DOM. ([MapVisualization.vue:2]).
  4. Then we initialize the Canvas with the previously calculated values in [MapVisualization.vue@initCanvas]
    1. Get the 2d context of the canvas to enable rendering of 2d images (this.ctx)
    2. Set the dimensions of it to prepare for rendering
  5. Now we initialize the “camera” by setting up the this.camera object to be in the center of the map
  6. At the end of the initialization we do the first rendering pass as if we had just gotten new map data using [MapVisualization.vue@handleNewMapData]

Now the canvas is initialized and the first render pass of the map data is completed.
Note that this map initialization is run at 2 points of the components’ lifecycle.
First, once it gets “mounted” ([MapVisualization.vue:160]). At this point there is no data and the map will just be blank. However, the controls are already shown and “work”. (But you can’t see that because nothing is rendered).
The next point is the first time any of the layer props actually gets updated with data. [MapVisualization.vue:130]. This makes sure that the map has a freshly initialized canvas once real data comes in.

Rendering explanation

Rendering the map works a lot like described in the MDN examples mentioned above. We render the layers one by one on top of each other. This causes the desired effect of not having to keep track of an exponential amount of planet / tile states and having to maintain a separate sprite for each one since we can now generate every possible state of a planet using just the few sprites in our tilemap.

The base entrypoint for rendering the map is [MapVisualization.vue@handleNewMapData] which gets called every time one of the layer arrays changes. It basically does 2 things. First it renders the entire map, captures a picture and sets that as the background for the “minimap” which also acts as the position control for the camera. Then it renders the new data with the camera using [MapVisualization.vue@render].
This is the core function for rendering the map. It does 2 things:

  1. It recalculates the needed values to properly render the map on the canvas using [MapVisualization.vue@setMapDimensions] and sets them using [MapVisualization.vue@updateCanvas]
  2. Now it actually draws the map with [MapVisualization.vue@drawMapWithCamera].

To do that a few more steps are required, but they work a lot like the example given by the MDN articles.

  1. Clear the canvas i.e. set it to be fully transparent to provide a clean base for drawing ([MapVisualization.vue@clearCanvas])
  2. Clamp the scrolling. This makes sure that the camera position does not mess up the rendering. Since we determine what tiles are to be rendered using math to tell us if a certain tile is in the viewport, we must ensure that the scrolling doesn’t go out of bounds, or it would mess up the rendering. If we would not clamp the scrolling, panning all the way to the left or right would start rendering the opposing side of the map and panning all the way to the top or bottom would just be transparent.
  3. Now we actually start rendering. Since we are now drawing pixels on the screen based on a tile grid, we base a lot of the needed calculations of off this.tileResolution which defines how many pixels one sprite is in width and height. This implies that every sprite must be the same size, currently 64.
    We need to calculate the following values:
    1. The start and end columns and rows for the tiles that are currently visible by the camera.
    2. The offsets i.e. how much we need to offset the positions of the other tiles to get the correct pixel position on the canvas
  4. Now we have all the values we need and can start to draw stuff to the canvas. For this we loop through all the layers and loop through each layer by column and row.
    Then we get the value for each tile by layer, row and col from [MapVisualization.vue@getTile].
    Now we calculate the x and y pixels on the canvas for the top left corner of the sprite.
    Lastly we finally draw the image using the ctx.drawImage method provided by the canvas api.

Other interesting points in the code

View manipulation helper functions
  • [MapVisualization.vue@mapOverview]: renders the entirety of the map at once. Also used to generate the picture for the minimap.
  • [MapVisualization.vue@centerCamera]: Does what it says on the tin. Center the camera view in the center of the map.
  • [MapVisualization.vue@resetCamera]: Resets the camera to the initial view after initializing
Layer watchers to update the map when it’s necessary

In the “watch” section of the Vue SFC we can define watchers that fire once the data of a prop has changed. If we detect a change we set that data (layer array) into the big layers array that contains all layers and will be used for rendering. The layers array also needs to be watched since changes to it trigger the re-rendering of the map. But since we are using (nested) arrays here we have to use some special methods to make vue actually realize when we change the data. First the layers watcher has to have the option deep: true to enable watching nested arrays. But this is not enough. To trigger a reactive update of this array we need to use the vue helper this.$set when updating the layers array in the other watchers because the watcher would not realize something changes when just assigning the variable like any other using e.g. this.layers[0] = this.gravityLayer.

Future Goals

  • Make the map draggable (Another method of moving the camera instead of dragging it using the “minimap”)