Map Visualization
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
-
Generate a new Gitlab Access token with only api rights. Replace GITLAB_ACCESS_TOKEN in the below commands with it.
-
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
-
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 andrequire
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!
- Vue.Js: See
src/App.vue
- React.Js: See React Example
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:
(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:
(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:
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)
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:
- The concept of tilemaps and layers
- Rendering square maps based on tilemaps
- Advanced rendering of those maps using a camera
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:
- Load the tile sprites from the image file using
[MapVisualization.vue@loadTiles]
- Image file that will be loaded:
/src/assets/mapTiles.png
- Image file that will be loaded:
- 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
- Now we set
this.renderMap
totrue
which causes Vue to render the canvas and the other elements to the DOM. ([MapVisualization.vue:2]
). - Then we initialize the Canvas with the previously calculated values in
[MapVisualization.vue@initCanvas]
- Get the 2d context of the canvas to enable rendering of 2d images (
this.ctx
) - Set the dimensions of it to prepare for rendering
- Get the 2d context of the canvas to enable rendering of 2d images (
- Now we initialize the “camera” by setting up the
this.camera
object to be in the center of the map - 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:
- It recalculates the needed values to properly render the map on the canvas using
[MapVisualization.vue@setMapDimensions]
and sets them using[MapVisualization.vue@updateCanvas]
- 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.
- Clear the canvas i.e. set it to be fully transparent to provide a clean base for drawing (
[MapVisualization.vue@clearCanvas]
) - 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.
- 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:- The start and end columns and rows for the tiles that are currently visible by the camera.
- 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
- 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”)