HTML [Twine 2.0/SugarCube/Tweego] Original Zelda Dungeon Style "Minimap"

Jan 22, 2020
59
157
I've been working on my game adding new features as I go. As the title suggests, the game is developed in Twine using Tweego and SugarCube. I've reached a stumbling block pertaining to location maps (minimap?). Spent a few days researching various solutions and landed on HTML5 canvases. Not in love with it, doesn't want to play with Tweego whatsoever, but I'm coming up empty. I've seen others implement this feature so I'm hoping for some insight here. The best way I can describe what I'm after is an old school Zelda dungeon map with black boxes representing rooms. Here is my canvas code that achieved the basics of the appearance.

HTML:
<!DOCTYPE html>
<html>
  <head>
    <title>GAME</title>
    <style>
      body,
      html {
        background-color: black;
        width: 100%;
        height: 100%;
        padding: 0;
        margin: 0;
      }
      #canvas {
        border: 1px solid black;
        background-color: rgb(49, 49, 49);
        width: 512px;
        height: 288px;
      }
      #container {
        width: 512px;
        height: 288px;
        margin: auto;
      }
    </style>
  </head>
  <body>
    <div id="container">
      <canvas id="canvas" height="288px" width="512px"></canvas>
    </div>

    <script>
      var canvas = document.getElementById("canvas");
      var context = canvas.getContext("2d");

      var mapArray = [
        [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
        [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
        [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
        [-1, -1, -1, -1, -1, -1, 0, -1, -1, 0, 0, -1, -1, -1, -1, -1],
        [-1, -1, -1, -1, -1, 0, 0, -1, -1, 0, -1, -1, -1, -1, -1, -1],
        [-1, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1],
        [-1, -1, -1, -1, -1, -1, -1, 0, -1, -1, 0, -1, -1, -1, -1, -1],
        [-1, -1, -1, -1, -1, -1, -1, 0, -1, 0, 0, -1, -1, -1, -1, -1],
        [-1, -1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1, -1, -1, -1],
      ];
      var posX = 0;
      var posY = 0;

      var room = new Image();
      room.src = "room.png";
      room.onload = mapDraw;
      var redBox = new Image();
      redBox.src = "redBox.png";
      redBox.onload = mapDraw;

      var size = 32;

      function mapDraw() {
        for (var i = 0; i < mapArray.length; i++) {
          for (var j = 0; j < mapArray[i].length; j++) {
            switch (mapArray[i][j]) {
              case 0:
                context.drawImage(room, posX, posY, size, size);
                break;
              case 1:
                context.drawImage(redBox, posX, posY, size, size);
                break;
              default:
                break;
            }
            posX += size;
          }
          posY += size;
          posX = 0;
        }
      }
    </script>
  </body>
</html>
The result looks like this:
1641778474672.png

This is obviously a mockup and has no further functionality. I can script how to move the player around, but it would be nice to have something visual to represent it. No clue how others are achieving this in SugarCube. Any thoughts and ideas would be greatly appreciated.
 
Last edited:
Jan 22, 2020
59
157
Well, I finally got the canvas working, but still very much interested in other methods if anyone would like to share. Here is the solution I landed on:

The passage code:
Code:
:: Start
Your story begines here.
<canvas id="canvas" height="288px" width="512px"></canvas>

<img id="img-room" src="room.png" style="display: none;">
<img id="img-redBox" src="redBox.png" style="display: none;">
In the JavaScript file:
JavaScript:
$(document).one(":passagedisplay", function (ev) {
  var canvas = ev.content.querySelector("#canvas");
  var context = canvas.getContext("2d");

  console.log(canvas);
  var room = ev.content.querySelector("#img-room");
  room.onload = drawStuffs;
  console.log(room);
  var redBox = ev.content.querySelector("#img-redBox");
  redBox.onload = drawStuffs;
  console.log(redBox);

  function drawStuffs() {
    var size = 32;

    var mapArray = [
      [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
      [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
      [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
      [-1, -1, -1, -1, -1, -1, 0, -1, -1, 0, 0, -1, -1, -1, -1, -1],
      [-1, -1, -1, -1, -1, 0, 0, -1, -1, 0, -1, -1, -1, -1, -1, -1],
      [-1, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1],
      [-1, -1, -1, -1, -1, -1, -1, 0, -1, -1, 0, -1, -1, -1, -1, -1],
      [-1, -1, -1, -1, -1, -1, -1, 0, -1, 0, 0, -1, -1, -1, -1, -1],
      [-1, -1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1, -1, -1, -1],
    ];

    var posX = 0;
    var posY = 0;

    for (var i = 0; i < mapArray.length; i++) {
      for (var j = 0; j < mapArray[i].length; j++) {
        switch (mapArray[i][j]) {
          case 0:
            context.drawImage(room, posX, posY, size, size);
            break;
          case 1:
            context.drawImage(redBox, posX, posY, size, size);
            break;
          default:
            break;
        }
        posX += size;
      }
      posY += size;
      posX = 0;
    }
  }
});
Now all I'd have to do is make the mapArray dynamic and a few other adjustments, but it would be workable. Should be in the header or footer I assume. Might work. I think... lol

The result:
1641791089758.png
 

HiEv

Member
Sep 1, 2017
384
778
Well, first thing I'd change is to check SugarCube story variables to determine which box to mark red based on the player's current position (i.e. use State.variables.posX to get the value of $posX). That way you don't need to track updates to the whole map array. However, if the map array may change throughout the course of the game, then you'll need to move that array onto a story variable as well, instead of embedding the array within the drawStuffs() function.

Next, you might want to have it so that the code within the :passagedisplay event handler only does something if the canvas actually exists. That would prevent errors which would occur if you went to a passage where the canvas wasn't present for some reason. (You can use if ($("canvas#canvas").length) { ... } to do that.)

Also, you might want to consider moving the map to the UI bar on the left by putting the passage code within the , though you'll have to scale the map down a bit to fit. You could even use the if you wanted to have your own UI layout.

I'm not sure if you were looking for any other help or anything, but I thought I'd chime in with a few suggestions.

Have fun! :)

P.S. Very minor note, but instead of "Your story begines here." it should be "Your story begins here.".

EDIT: Fixed the $("canvas#canvas") code.
 
Last edited:
Jan 22, 2020
59
157
Well, first thing I'd change is to check SugarCube story variables to determine which box to mark red based on the player's current position (i.e. use State.variables.posX to get the value of $posX). That way you don't need to track updates to the whole map array. However, if the map array may change throughout the course of the game, then you'll need to move that array onto a story variable as well, instead of embedding the array within the drawStuffs() function.
Totally agree. Took me a while to figure out how to get to the canvas. Was racking my brain most of the day. Tomorrow I'm going to clean it up and change the drawStuffs() function to something more appropriate. lol

Next, you might want to have it so that the code within the :passagedisplay event handler only does something if the canvas actually exists. That would prevent errors which would occur if you went to a passage where the canvas wasn't present for some reason. (You can use if ($("canvas.canvas").length) { ... } to do that.)
Solid idea. I'll do that!

Also, you might want to consider moving the map to the UI bar on the left by putting the passage code within the , though you'll have to scale the map down a bit to fit. You could even use the if you wanted to have your own UI layout.
Not sure where I want to put it, but I agree it would be useful there. Might need to witch to 16x16 pixels if I do.


I'm not sure if you were looking for any other help or anything, but I thought I'd chime in with a few suggestions.

Have fun! :)
Oh, I'm sure I'll need lots of help lol. It's always welcome. :)

P.S. Very minor note, but instead of "Your story begines here." it should be "Your story begins here.".
LOL very true! Thanks!
 
Jan 22, 2020
59
157
Also, you might want to consider moving the map to the UI bar on the left by putting the passage code within the , though you'll have to scale the map down a bit to fit. You could even use the if you wanted to have your own UI layout.
So, I did this today, but can't figure out how to access the
Code:
story-caption
element to get my canvas. My previous code only included the Start event. Any insight on how to target elements out of the UI bar?

HTML:
<div id="passage-start" data-passage="Start" class="passage">
    Your story begines here.
    <br>
    <canvas id="canvas" height="288px" width="512px"></canvas>
    <br>
    <img id="img-room" src="room.png" style="display: none;">
    <br>
    <img id="img-redBox" src="redBox.png" style="display: none;">
</div>
 

HiEv

Member
Sep 1, 2017
384
778
So, I did this today, but can't figure out how to access the story-caption element to get my canvas. My previous code only included the Start event. Any insight on how to target elements out of the UI bar?
You'll need to modify things a bit. First, create a passage named "StoryCaption" and put this in it:
HTML:
<canvas id="canvas" width="516px" height="292px"></canvas>
Next, create a passage named "StoryInit" and put this in it to set the player's initial position on the map:
Code:
<<set $posX = 7>>
<<set $posY = 8>>
Keep in mind that position (0, 0) is the top-left corner of the map as it's rendered currently.

Then put this in your JavaScript section:
JavaScript:
$(document).on(":passageend", function (ev) {
    if ($("canvas#canvas").length) {
        var canvas = document.querySelector("#canvas");
        var context = canvas.getContext("2d");
        var mapArray = [
            [0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0],
            [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
            [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
            [-1, -1, -1, -1, -1, -1, 0, -1, -1, 0, 0, -1, -1, -1, -1, -1],
            [-1, -1, -1, -1, -1, 0, 0, -1, -1, 0, -1, -1, -1, -1, -1, -1],
            [-1, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1],
            [-1, -1, -1, -1, -1, -1, -1, 0, -1, -1, 0, -1, -1, -1, -1, -1],
            [-1, -1, -1, -1, -1, -1, -1, 0, -1, 0, 0, -1, -1, -1, -1, -1],
            [0, -1, -1, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, -1, -1, 0],
        ];
        var blocksize = 30, gap = 2, size = blocksize + gap;
        context.fillStyle = 'black';
        context.fillRect(0, 0, mapArray[0].length * size + (gap * 2), mapArray.length * size + (gap * 2));
        for (var i = 0; i < mapArray.length; i++) {
            for (var j = 0; j < mapArray[i].length; j++) {
                if ((j === State.variables.posX) && (i === State.variables.posY)) {
                    context.fillStyle = 'red';
                    context.fillRect(j * size + gap + 1, i * size + gap + 1, blocksize, blocksize);
                } else {
                    switch (mapArray[i][j]) {
                        case 0:
                            context.fillStyle = 'grey';
                            context.fillRect(j * size + gap + 1, i * size + gap + 1, blocksize, blocksize);
                            break;
                    }
                }
            }
        }
    }
});
That will then draw the map based on the array and the values of $posX and $posY.

Note that I switched from using PNG images to just using the , since then you aren't waiting for images to load. See the for details on using that to determine what .fillRect() fills the rectangles with.

I had to switch to using the to get it to wait for the UI bar to be fully rendered initially as well.

I also added corner squares to the map just so you could see the boundaries of the map when testing, but those can be set back to -1 if you want.

Please let me know if you have any questions on how any of that works.

Enjoy! :)
 
Last edited:
Jan 22, 2020
59
157
Please let me know if you have any questions on how any of that works.

Enjoy! :)
Well, I borrowed your code and beat it into submission. Thanks for that! I made some changes, moved a few variables, and added some upgraded features. Here is the codes I used to make it all go. Not 100% finished, but functions correctly.

The passageend code:
JavaScript:
$(document).on(":passageend", function (ev) {
  if ($("canvas#canvas").length) {
    var canvas = document.querySelector("#canvas");
    var context = canvas.getContext("2d");
    var zone = State.variables.currentZone;
    var currentPosition = zone.getCurrentPosition();
    var mapArray = zone.getMap();
    var blocksize = 24,
      gap = 4,
      size = blocksize + gap * 2;
    context.fillStyle = "#242424";
    context.fillRect(
      0,
      0,
      mapArray[0].length * size + gap * 2,
      mapArray.length * size + gap * 2
    );
    mapArray.forEach(function (row, rowIndex) {
      row.forEach(function (cell, cellIndex) {
        if (cell != -1) {
          switch (cell.getVisited()) {
            case false:
              if (zone.isMapUnlocked()) {
                context.fillStyle = "#8aa0bf";
                context.fillRect(
                  cellIndex * size + gap,
                  rowIndex * size + gap,
                  blocksize,
                  blocksize
                );
              }
              break;
            case true:
              context.fillStyle = "#505d6e";
              context.fillRect(
                cellIndex * size + gap,
                rowIndex * size + gap,
                blocksize,
                blocksize
              );
              break;
          }
          if (cell.getVisited() || zone.isMapUnlocked()) {
            var dirs = cell.getTravelDirecitons();
            if (dirs.north) {
              context.fillStyle = "#00b007";
              context.fillRect(
                cellIndex * size + size / 2 - gap,
                rowIndex * size - gap,
                gap * 2,
                gap * 2
              );
            }
            if (dirs.south) {
              context.fillStyle = "#00b007";
              context.fillRect(
                cellIndex * size + size / 2 - gap,
                rowIndex * size + size - gap,
                gap * 2,
                gap * 2
              );
            }
            if (dirs.east) {
              context.fillStyle = "#00b007";
              context.fillRect(
                cellIndex * size + size - gap,
                rowIndex * size + size / 2 - gap,
                gap * 2,
                gap * 2
              );
            }
            if (dirs.west) {
              context.fillStyle = "#00b007";
              context.fillRect(
                cellIndex * size - gap,
                rowIndex * size + size / 2 - gap,
                gap * 2,
                gap * 2
              );
            }
          }
        }
        if (cellIndex === currentPosition.x && rowIndex === currentPosition.y) {
          context.fillStyle = "#00dbd0";
          context.fillRect(
            cellIndex * size + gap,
            rowIndex * size + gap,
            blocksize,
            blocksize
          );
        }
      });
    });
  }
});
The passages:
Code:
:: StoryInit
<<include initZone>>

:: Start
Your story begins here.
<<button "North" "Start">>
  <<run $currentZone.moveNorth()>>
<</button>>
<<nobr>>
  <<button "West" "Start">>
    <<run $currentZone.moveWest()>>
  <</button>>
  <<button "East" "Start">>
    <<run $currentZone.moveEast()>>
  <</button>>
<</nobr>>
   <<button "South" "Start">>
  <<run $currentZone.moveSouth()>>
<</button>>

:: StoryCaption
<<set $cp = $currentZone.getCurrentPosition()>>
<<= $currentZone.getName()>> - <<= $currentZone.getCurrentMapNode().getName()>> (<<= $cp.y + "," + $cp.x >>)
<canvas id="canvas" height="288px" width="512px"></canvas>
<br>
<br>
<br>
<br>

:: initZone
<<nobr>>
<<set $currentZone = new setup.ZoneNode(999, "Test Zone", 7, 8)>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Entrace", 7, 8, "", {north: true, south: false, east: false, west: false}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Reception", 7, 7, "", {north: true, south: true, east: false, west: false}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Corridor #1", 7, 6, "", {north: true, south: true, east: false, west: false}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Corridor #2", 7, 5, "", {north: true, south: true, east: true, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Corridor #2", 7, 4, "", {north: false, south: true, east: false, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Corridor #2", 6, 4, "", {north: false, south: false, east: true, west: false}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Corridor #2", 6, 5, "", {north: false, south: false, east: true, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Corridor #2", 5, 5, "", {north: false, south: false, east: true, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Corridor #2", 4, 5, "", {north: false, south: false, east: true, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Corridor #2", 3, 5, "", {north: false, south: false, east: true, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Corridor #2", 2, 5, "", {north: false, south: false, east: true, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Corridor #2", 1, 5, "", {north: false, south: false, east: true, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Corridor #2", 0, 5, "", {north: false, south: false, east: true, west: false}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Corridor #3", 8, 5, "", {north: true, south: false, east: true, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Restroom", 8, 4, "", {north: true, south: true, east: false, west: false}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Restroom", 8, 3, "", {north: true, south: true, east: false, west: false}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Restroom", 8, 2, "", {north: true, south: true, east: false, west: false}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Restroom", 8, 1, "", {north: true, south: true, east: false, west: false}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Restroom", 8, 0, "", {north: false, south: true, east: false, west: false}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Shop", 9, 5, "", {north: false, south: false, east: true, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Shop", 10, 5, "", {north: false, south: false, east: true, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Shop", 11, 5, "", {north: false, south: false, east: true, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Shop", 12, 5, "", {north: false, south: false, east: true, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Shop", 13, 5, "", {north: false, south: false, east: true, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Shop", 14, 5, "", {north: false, south: false, east: true, west: true}))>>
<<run $currentZone.addMapNode(new setup.MapNode(99, "Shop", 15, 5, "", {north: false, south: false, east: false, west: true}))>>
<<run $currentZone.startZone()>>
<</nobr>>\
My custom objects:
JavaScript:
window.setup = window.setup || {};

setup.ZoneNode = function (uid, name, startPosX, startPosY) {
  this.currentPosX = startPosX;
  this.currentPosY = startPosY;
  this.mapNodes = [];
  this.map = [];
  this.name = name;
  this.mapUnlocked = true;
  this.startPosX = startPosX;
  this.startPosY = startPosY;
  this.uid = uid;
  this.visited = false;

  this.addMapNode = function (mapNode) {
    this.mapNodes.push(mapNode);
    this.buildMap();
    return true;
  };

  this.buildMap = function () {
    this.initMap();
    var maps = this.map;
    this.mapNodes.forEach(function (item) {
      var coords = item.getCoordinates();
      maps[coords.posY][coords.posX] = item;
    });
    return true;
  };

  this.getCurrentMapNode = function () {
    return this.map[this.currentPosY][this.currentPosX];
  };

  this.getCurrentPosition = function () {
    return { x: this.currentPosX, y: this.currentPosY };
  };

  this.getMap = function () {
    return this.map;
  };

  this.getName = function () {
    return this.name;
  };

  this.getPassage = function () {
    return this.passage;
  };

  this.markVisited = function () {
    this.visited = true;
    return true;
  };

  this.getUID = function () {
    return this.uid;
  };

  this.initMap = function () {
    this.map = [];
    for (var i = 0; i < 9; i++) {
      this.map.push([]);
      for (var j = 0; j < 16; j++) {
        this.map[i].push(-1);
      }
    }
  };

  this.isMapUnlocked = function () {
    return this.mapUnlocked;
  };

  this.moveEast = function () {
    var x = this.currentPosX;
    if (x + 1 < 16) {
      var nextRoom = this.map[this.currentPosY][x + 1];
      if (this.getCurrentMapNode().getTravelDirecitons().east) {
        this.currentPosX += 1;
        nextRoom.markVisited();
      }
    }
  };

  this.moveNorth = function () {
    var y = this.currentPosY;
    if (y - 1 > -1) {
      var nextRoom = this.map[y - 1][this.currentPosX];
      if (this.getCurrentMapNode().getTravelDirecitons().north) {
        this.currentPosY -= 1;
        nextRoom.markVisited();
      }
    }
  };

  this.moveSouth = function () {
    var y = this.currentPosY;
    if (y + 1 < 9) {
      var nextRoom = this.map[y + 1][this.currentPosX];
      if (this.getCurrentMapNode().getTravelDirecitons().south) {
        this.currentPosY += 1;
        nextRoom.markVisited();
      }
    }
  };

  this.moveWest = function () {
    var x = this.currentPosX;
    if (x - 1 > -1) {
      var nextRoom = this.map[this.currentPosY][x - 1];
      if (this.getCurrentMapNode().getTravelDirecitons().west) {
        this.currentPosX -= 1;
        nextRoom.markVisited();
      }
    }
  };

  this.startZone = function () {
    this.currentPosX = this.startPosX;
    this.currentPosY = this.startPosY;
    var mapNode = this.map[this.currentPosY][this.currentPosX];
    console.log(mapNode);
    mapNode.markVisited();
  };

  this.unlockMap = function () {
    this.mapUnlocked = true;
  };
};

setup.MapNode = function (uid, name, posX, posY, passage, travelDirections) {
  this.name = name;
  this.posX = posX;
  this.posY = posY;
  this.passage = passage;
  this.travelDirections = {
    north: travelDirections.north,
    south: travelDirections.south,
    east: travelDirections.east,
    west: travelDirections.west,
  };
  this.uid = uid;
  this.visited = false;

  this.getCoordinates = function () {
    return { posX: this.posX, posY: this.posY };
  };

  this.getName = function () {
    return this.name;
  };

  this.getPassage = function () {
    return this.passage;
  };

  this.getTravelDirecitons = function () {
    return this.travelDirections;
  };

  this.getUID = function () {
    return this.uid;
  };

  this.getVisited = function () {
    return this.visited;
  };

  this.markVisited = function () {
    this.visited = true;
    return true;
  };
};
In summery, I've added the ability to move from room to room, provided there is a "door" between them. Rooms that haven't been visited are a lighter color than rooms that have. I've added green lines between rooms that can be traveled to indicate there is a pathway between. Everything is pretty much there now, just need the part that triggers the stored passage when a room is entered. I'm also going to add events to rooms, create a check for when a player enters it for special scenes. Finally things are cooking again. Thanks for your help!

1641959073211.png
 

HiEv

Member
Sep 1, 2017
384
778
Well, I borrowed your code and beat it into submission. Thanks for that! I made some changes, moved a few variables, and added some upgraded features. Here is the codes I used to make it all go. Not 100% finished, but functions correctly.
Well, you're doing one thing that doesn't look like it's going to work right, at least not as-is. You're setting $currentZone to a custom "class", which isn't really supported in SugarCube (see the " " section of the SugarCube documentation). If you're going to use that structure, I'd recommend reading the " " part of the documentation, and implementing the .clone() and .toJSON() methods as described there. Without that, the code is likely to break, especially when saving/loading the game.

That's why, instead of doing what you did, I tend to just create generic objects and then make separate functions that work with those objects.

Furthermore, I'll let you know that you really should try to minimize the size of the game's history by putting as little data on your story variables as possible. If you don't, the history bloat will slow down your game's saves, loads, and passage transitions more and more as the history gets filled up with more unnecessary data. Which is yet another reason to just use standard JavaScript primitives and objects, and keep the functions out of your variables.

In that same vein of minimizing history bloat, instead of storing { north: false, south: false, east: true, west: false } as part of the map's nodes, I'd just store { east: true } and assume that any non-true directions are false by default. That will help cut down on history bloat, and save you a bunch of typing for all of the directions that don't matter. You can then just do something like if (directions.east) { ... } to determine if the "directions" object has an "east" property that's set to a " " value. If the "east" property doesn't exist on the object, then it will be undefined, which is a " " value.

In fact, you could cut down the amount of data that needs to be stored there even further if you just assume that all adjacent nodes are connected, unless otherwise indicated. Then you'd only need to indicate in the map's nodes when there's not a connection to an adjacent node. In your current map that would mean you'd only need to indicate that a wall exists in just four places (between (6,4) & (6,5) and between (7,4) & (8,4) ), compared to the 104 directions you have listed in your code currently.

Then again, you don't really need to store the whole map layout in a story variable at all, unless the map may change. All you really need to store is whatever parts of the map there are that may change (such as whether the player has visited a node in the map). Everything else which never changes could be stored on the setup object or in your functions.

Have fun! :)
 
Last edited:
Jan 22, 2020
59
157
Well, you're doing one thing that doesn't look like it's going to work right, at least not as-is. You're setting $currentZone to a custom "class", which isn't really supported in SugarCube (see the " " section of the SugarCube documentation). If you're going to use that structure, I'd recommend reading the " " part of the documentation, and implementing the .clone() and .toJSON() methods as described there. Without that, the code is likely to break, especially when saving/loading the game.

That's why, instead of doing what you did, I tend to just create generic objects and then make separate functions that work with those objects.
Okay, this makes good sense. I could move all the functions into Setup then have the previous class object return a simple object of values. Or, as you appear to suggest, store simple objects that only retain the modified values from the base state of the object. This will take quite a bit of refactoring, but in the end it will be better off and simpler to follow.

Furthermore, I'll let you know that you really should try to minimize the size of the game's history by putting as little data on your story variables as possible. If you don't, the history bloat will slow down your game's saves, loads, and passage transitions more and more as the history gets filled up with more unnecessary data. Which is yet another reason to just use standard JavaScript primitives and objects, and keep the functions out of your variables.

In that same vein of minimizing history bloat, instead of storing { north: false, south: false, east: true, west: false } as part of the map's nodes, I'd just store { east: true } and assume that any non-true directions are false by default. That will help cut down on history bloat, and save you a bunch of typing for all of the directions that don't matter. You can then just do something like if (directions.east) { ... } to determine if the "directions" object has an "east" property that's set to a " " value. If the "east" property doesn't exist on the object, then it will be undefined, which is a " " value.

In fact, you could cut down the amount of data that needs to be stored there even further if you just assume that all adjacent nodes are connected, unless otherwise indicated. Then you'd only need to indicate in the map's nodes when there's not a connection to an adjacent node. In your current map that would mean you'd only need to indicate that a wall exists in just four places (between (6,4) & (6,5) and between (7,4) & (8,4) ), compared to the 104 directions you have listed in your code currently.
I always seem to forget that JavaScript works this way and doesn't just throw an error when something is null or undefined. I'll probably change directions.north to walls.north since it will be more clear, so if walls.north is true the player can't move that direction and the drawing code should not display a link between rooms.

Then again, you don't really need to store the whole map layout in a story variable at all, unless the map may change. All you really need to store is whatever parts of the map there are that may change (such as whether the player has visited a node in the map). Everything else which never changes could be stored on the setup object or in your functions.
I like this concept as well. I will need the ability to store multiple maps, but not necessarily save them. They can exist in Setup and be called upon when the player is in the zone it represents. It will minimize my update scripts with future releases as well, since the save file will only retain what has been visited. So, if I decide to add a room or two later it won't cause me too much grief.
 
  • Like
Reactions: HiEv
Jan 22, 2020
59
157
I finished modifying the system to use less history memory and to rely more on setup variables. All objects are now simple or generic objects. I now have a few considerations, having dug out the old stuff and built the new. I'll list out the code before I go into them.

The passageend code:
I moved the content to a function, that way I can add other features on the same event call later without it becoming a huge messy ball.
JavaScript:
window.setup = window.setup || {};

$(document).on(":passageend", function (ev) {
  setup.drawMiniMap();
});

setup.drawMiniMap = function () {
  var zone = setup.getZone(State.variables.currentZone);
  var room = setup.getRoom(zone.ID, State.variables.currentRoom);
  var currentPosX = State.variables.currentPosX;
  var currentPosY = State.variables.currentPosY;
  if ($("canvas#map-canvas").length) {
    var canvas = document.querySelector("#map-canvas");
    var context = canvas.getContext("2d");
    var zoneMap = setup.buildZoneMap(zone);
    var blocksize = 24,
      gap = 4,
      size = blocksize + gap * 2;
    context.fillStyle = "#242424";
    context.fillRect(
      0,
      0,
      zoneMap[0].length * size + gap * 2,
      zoneMap.length * size + gap * 2
    );
    zoneMap.forEach(function (row, rowIndex) {
      row.forEach(function (cell, cellIndex) {
        if (cell != -1) {
          switch (setup.isRoomVisited(zone.ID, cell.ID)) {
            case false:
              if (setup.isMapUnlocked(zone.ID)) {
                context.fillStyle = "#8aa0bf";
                context.fillRect(
                  cellIndex * size + gap,
                  rowIndex * size + gap,
                  blocksize,
                  blocksize
                );
              }
              break;
            case true:
              context.fillStyle = "#505d6e";
              context.fillRect(
                cellIndex * size + gap,
                rowIndex * size + gap,
                blocksize,
                blocksize
              );
              break;
          }
          if (
            setup.isRoomVisited(zone.ID, cell.ID) ||
            setup.isMapUnlocked(zone.ID)
          ) {
            var walls = cell.walls;
            if (setup.canMoveNorth(zoneMap, cell)) {
              context.fillStyle = "#00b007";
              context.fillRect(
                cellIndex * size + size / 2 - gap,
                rowIndex * size - gap,
                gap * 2,
                gap * 2
              );
            }
            if (setup.canMoveSouth(zoneMap, cell)) {
              context.fillStyle = "#00b007";
              context.fillRect(
                cellIndex * size + size / 2 - gap,
                rowIndex * size + size - gap,
                gap * 2,
                gap * 2
              );
            }
            if (setup.canMoveEast(zoneMap, cell)) {
              context.fillStyle = "#00b007";
              context.fillRect(
                cellIndex * size + size - gap,
                rowIndex * size + size / 2 - gap,
                gap * 2,
                gap * 2
              );
            }
            if (setup.canMoveWest(zoneMap, cell)) {
              context.fillStyle = "#00b007";
              context.fillRect(
                cellIndex * size - gap,
                rowIndex * size + size / 2 - gap,
                gap * 2,
                gap * 2
              );
            }
          }
        }
        if (cellIndex === currentPosX && rowIndex === currentPosY) {
          context.fillStyle = "#00dbd0";
          context.fillRect(
            cellIndex * size + gap,
            rowIndex * size + gap,
            blocksize,
            blocksize
          );
          if (!setup.isRoomVisited(zone.ID, room.ID)) {
            setup.unlockRoom(zone.ID, room.ID);
          }
        }
      });
    });
  }
  return true;
};
The passages:
Code:
:: StoryTitle
Darkside Cove

:: StoryInit
<<include NavigationInit>>
<<run setup.unlockZone(99, $navigationList)>>
<<run setup.enterZone($currentZone)>>

:: Start
Your story begins here.
<<button "North" "Start">>
  <<run setup.moveNorth($currentZone, $currentRoom)>>
<</button>>
<<nobr>>
  <<button "West" "Start">>
    <<run setup.moveWest($currentZone, $currentRoom)>>
  <</button>>
  <<button "East" "Start">>
    <<run setup.moveEast($currentZone, $currentRoom)>>
  <</button>>
<</nobr>>
   <<button "South" "Start">>
  <<run setup.moveSouth($currentZone, $currentRoom)>>
<</button>>

:: StoryCaption
<<= setup.getZone($currentZone).name>> - <<= setup.getRoom($currentZone, $currentRoom).name>> (<<= $currentPosY + "," + $currentPosX >>)
<canvas id="map-canvas" height="288px" width="512px"></canvas>
<br>
<br>
<br>
<br>

:: NavigationInit
<<set $navigationList = {}>>
<<set $currentZone = 99>>
New Functions:
JavaScript:
window.setup = window.setup || {};
setup.zones = setup.zones || {};

setup.buildZoneMap = function (zone) {
  var map = [];
  for (var i = 0; i < 9; i++) {
    map.push([]);
    for (var j = 0; j < 16; j++) {
      map[i].push(-1);
    }
  }
  var rooms = zone.rooms;
  rooms.forEach(function (item) {
    map[item.posY][item.posX] = item;
  });
  return map;
};

setup.canMoveEast = function (map, currentRoom) {
  var posX = currentRoom.posX;
  var posY = currentRoom.posY;
  var walls = currentRoom.walls;
  if (
    !walls.east &&
    map[posY][posX + 1] != -1 &&
    map[posY][posX + 1] != undefined
  ) {
    return true;
  } else {
    return false;
  }
};

setup.canMoveNorth = function (map, currentRoom) {
  var posX = currentRoom.posX;
  var posY = currentRoom.posY;
  var walls = currentRoom.walls;
  if (!walls.north && posY - 1 != -1 && map[posY - 1][posX] != -1) {
    return true;
  } else {
    return false;
  }
};

setup.canMoveSouth = function (map, currentRoom) {
  var posX = currentRoom.posX;
  var posY = currentRoom.posY;
  var walls = currentRoom.walls;
  if (
    !walls.south &&
    map[posY + 1][posX] != -1 &&
    map[posY + 1][posX] != undefined
  ) {
    return true;
  } else {
    return false;
  }
};

setup.canMoveWest = function (map, currentRoom) {
  var posX = currentRoom.posX;
  var posY = currentRoom.posY;
  var walls = currentRoom.walls;
  if (!walls.west && posX - 1 != -1 && map[posY][posX - 1] != -1) {
    return true;
  } else {
    return false;
  }
};

setup.createRoom = function (zoneID, roomID, name, posX, posY, walls = {}) {
  var zone = setup.getZone(zoneID);
  if (zone) {
    if (!zone.rooms[roomID]) {
      zone.rooms[roomID] = {
        ID: roomID,
        name: name,
        posX: posX,
        posY: posY,
        walls: walls,
      };
    } else {
      throw "roomID already exists: " + roomID;
    }
  } else {
    throw "roomID could not be created: " + roomID;
  }
};

setup.createZone = function (zoneID, name, startRoom) {
  if (!setup.zones[zoneID]) {
    setup.zones[zoneID] = {
      ID: zoneID,
      rooms: [],
      name: name,
      startRoom: startRoom,
    };
  } else {
    throw "zoneID already exists: " + zoneID;
  }
};

setup.enterZone = function (zoneID) {
  var zone = setup.getZone(zoneID);
  var startRoom = setup.getRoom(zoneID, zone.startRoom);
  State.variables.currentPosX = startRoom.posX;
  State.variables.currentPosY = startRoom.posY;
  State.variables.currentRoom = zone.startRoom;
};

setup.getRoom = function (zoneID, roomID) {
  var zone = setup.getZone(zoneID);
  if (zone) {
    if (zone.rooms[roomID]) {
      return zone.rooms[roomID];
    } else {
      throw "roomID does not exists: " + roomID;
    }
  } else {
    throw "Could not get room: " + roomID;
  }
};

setup.getZone = function (zoneID) {
  if (setup.zones[zoneID]) {
    return setup.zones[zoneID];
  } else {
    throw "zoneID does not exists: " + zoneID;
  }
};

setup.isMapUnlocked = function (zoneID) {
  var nList = State.variables.navigationList;
  if (nList[zoneID].unlocked) {
    return true;
  } else {
    return true;
  }
};

setup.isRoomVisited = function (zoneID, roomID) {
  var nList = State.variables.navigationList;
  if (nList[zoneID]) {
    if (nList[zoneID].rooms[roomID]) {
      if (nList[zoneID].rooms[roomID].visted) {
        return true;
      } else {
        return false;
      }
    } else {
      return false;
    }
  } else {
    return false;
  }
};

setup.moveEast = function (currentZone, currentRoom) {
  var zone = setup.getZone(currentZone);
  var room = setup.getRoom(currentZone, currentRoom);
  var map = setup.buildZoneMap(zone);
  if (setup.canMoveEast(map, room)) {
    var nextRoom = map[room.posY][room.posX + 1];
    State.variables.currentRoom = nextRoom.ID;
    State.variables.currentPosX = nextRoom.posX;
    State.variables.currentPosY = nextRoom.posY;
  }
};

setup.moveNorth = function (currentZone, currentRoom) {
  var zone = setup.getZone(currentZone);
  var room = setup.getRoom(currentZone, currentRoom);
  var map = setup.buildZoneMap(zone);
  if (setup.canMoveNorth(map, room)) {
    var nextRoom = map[room.posY - 1][room.posX];
    State.variables.currentRoom = nextRoom.ID;
    State.variables.currentPosX = nextRoom.posX;
    State.variables.currentPosY = nextRoom.posY;
  }
};

setup.moveSouth = function (currentZone, currentRoom) {
  var zone = setup.getZone(currentZone);
  var room = setup.getRoom(currentZone, currentRoom);
  var map = setup.buildZoneMap(zone);
  if (setup.canMoveSouth(map, room)) {
    var nextRoom = map[room.posY + 1][room.posX];
    State.variables.currentRoom = nextRoom.ID;
    State.variables.currentPosX = nextRoom.posX;
    State.variables.currentPosY = nextRoom.posY;
  }
};

setup.moveWest = function (currentZone, currentRoom) {
  var zone = setup.getZone(currentZone);
  var room = setup.getRoom(currentZone, currentRoom);
  var map = setup.buildZoneMap(zone);
  if (setup.canMoveWest(map, room)) {
    var nextRoom = map[room.posY][room.posX - 1];
    State.variables.currentRoom = nextRoom.ID;
    State.variables.currentPosX = nextRoom.posX;
    State.variables.currentPosY = nextRoom.posY;
  }
};

setup.setCurrentRoom = function (zoneID, roomID) {
  var zone = setup.getZone(zoneID);
  if (zone) {
    return setup.getroom(roomID);
  } else {
    throw "Could not set current room to: " + roomID;
  }
};

setup.setCurrentZone = function (zoneID) {
  return setup.getZone(zoneID);
};

setup.unlockZone = function (zoneID) {
  var zone = setup.getZone(zoneID);
  var nList = State.variables.navigationList;
  if (zone) {
    if (!nList[zoneID]) {
      nList[zoneID] = {
        active: true,
        mapUnlocked: false,
        rooms: {},
      };
    } else {
      throw "zoneID already unlocked: " + zoneID;
    }
  } else {
    throw "Unable to unlock zone: " + zoneID;
  }
};

setup.unlockRoom = function (zoneID, roomID) {
  var nList = State.variables.navigationList;
  var zone = setup.getZone(zoneID);
  var room = setup.getRoom(zoneID, roomID);
  if (zoneID && roomID) {
    if (!nList[zoneID].rooms[roomID]) {
      nList[zoneID].rooms[roomID] = {
        active: true,
        visted: true,
      };
    } else {
      throw "roomID already unlocked: " + roomID;
    }
  } else {
    throw "Unable to unlock room: " + roomID;
  }
};
Now, I could remove $currentPosX and $currentPosY because $currentRoom stores a reference to the room object, which stores its own posX and PosY. Seems redundant since you must fetch that object anyhow to resolve it's position.

I'm not entirely happy with how the Moving system works. Seems more complicated than it should. On the bright side, making rooms is much simpler now that we only care about walls, empty spaces, or situations where the player would move out of bounds. If there is a simpler way, it would make my code cleaner and tighter. Haven't thought of it just yet.

And finally, I have not found a solve just yet that doesn't require both rooms to be aware of the wall between them. It isn't huge deal. Just a fact at the moment. Just want to say thank you for all the help. It has helped me look at programing in Twine in a whole different way!
 

HiEv

Member
Sep 1, 2017
384
778
Now, I could remove $currentPosX and $currentPosY because $currentRoom stores a reference to the room object, which stores its own posX and PosY. Seems redundant since you must fetch that object anyhow to resolve it's position.
Generally speaking, removing redundant data is a good idea, not just for simplicity or saving space in the game history, but also to prevent bugs if the two things get out of synch because you set one and forgot to set the other at the same time.

I'm not entirely happy with how the Moving system works. Seems more complicated than it should. On the bright side, making rooms is much simpler now that we only care about walls, empty spaces, or situations where the player would move out of bounds. If there is a simpler way, it would make my code cleaner and tighter. Haven't thought of it just yet.
Yes, it can be simplified. Rather than having separate functions for each direction, it would make more sense to just check based on either the destination location or direction moved. For example, using the direction moved you could change your movement code to just this:
JavaScript:
setup.move = function (currentZone, currentRoom, moveX, moveY) {
    var nextRoom = setup.canMove(setup.buildZoneMap(setup.getZone(currentZone)), setup.getRoom(currentZone, currentRoom), moveX, moveY);
    if (nextRoom) {
        State.variables.currentRoom = nextRoom.ID;
        State.variables.currentPosX = nextRoom.posX;
        State.variables.currentPosY = nextRoom.posY;
    }
};

setup.dir = { "-1,0": "west", "1,0": "east", "0,-1": "north", "0,1": "south" };

setup.canMove = function (map, currentRoom, moveX, moveY) {
    var posX = currentRoom.posX + moveX;
    var posY = currentRoom.posY + moveY;
    var direction = setup.dir[moveX + "," + moveY];
    if (!map[posY][posX] || (map[posY][posX] === -1) || currentRoom.walls[direction]) {
        return false;
    } else {
        return map[posY][posX];
    }
};
Then you could just call those functions and indicate the movement direction by setting the moveX and moveY parameters. (Note: The code assumes one movement direction will always be 0 while the other will always be either -1 or 1.)

Thus, a button in your passage might look like this:
Code:
<<button "North" "Start">>
    <<run setup.move($currentZone, $currentRoom, 0, -1)>>
<</button>>
The setup.dir object gives a quick way to turn those movement directions into a direction name.

I also switched from using && (and) to || (or) in the setup.canMove() function, since &&s have to check every value to see if they're all true, while ||s only have to find one true and it doesn't bother to check the other values once one is found, so the latter is a little more efficient when looking for true values (it's the reverse when looking for false values).

The setup.canMove() function also returns the destination room if it's a valid movement, so that you don't have to grab it again in the setup.move() function.

And finally, I have not found a solve just yet that doesn't require both rooms to be aware of the wall between them. It isn't huge deal. Just a fact at the moment.
You'd just need to have it so that, if the destination room exists, it does an additional check for a wall on the opposite side of that destination room. That would allow you to cut the number of "walls" in your map in half, since you'd only need to track that for one of any pairs of rooms which share the wall.

That said, as things are now, you have the ability to create one-way passages if you want to, by putting a wall in only one of two adjacent rooms. Like an exit which is a steep slope you could slide down in one direction, but couldn't climb up in the other direction, or a hidden door which can only be opened from one side. Probably not needed, but the option is there.

Just want to say thank you for all the help. It has helped me look at programing in Twine in a whole different way!
Glad to help! :)

P.S. I just noticed that you were doing this: window.setup = window.setup || {}; There's no need to do that, SugarCube creates its own for you.
 
Last edited: