Writing in the front

This article will walk you through a different Super Mario Game. Online address, this game has a total of 5 levels, please tell us your total time to complete the first two levels in the comments section haha ~

The source address

introduce

The game interface looks like this:

The small black squares represent the player, whose task is to collect all the gold in the level while avoiding lava. When the last gold coin is collected, the level is passed.

How to operate?

The player moves through the up, down, left and right keys on the keyboard. Okay, so shall we start our game tour?

modeling

Define the level

First we need to represent our map in a way that should be as simple and easy to understand as possible. In this article we will use a large string to represent it. For example, a relatively simple map might look like this:

let simplePlan = 
`... . #... #.. . #... =. #.. . #... o.o.... #.. . #. @... # # # # #... #.. . # # # # #... #.. . # + + + + + + + + + + + + #.. . # # # # # # # # # # # # # #.. . `
Copy the code

Where period (.) Air, hash marks (#) for roads and walls, plus signs (+) for lava, the letter O for coins, and @ for the player’s starting position. Lava is can move at the same time, | representative of lava to move up and down = said level mobile lava, v says drip lava (will move down will not bounce back and forth), when the player encounters lava means that if you fail, then return to current levels the starting position of the start again.

Read the checkpoint

We need to define a class to store and parse levels.

class Level {
  constructor(plan) {
    let rows = plan.trim().split("\n").map(l= > [...l]);
    this.height = rows.length;
    this.width = rows[0].length;
    this.startActors = [];

    this.rows = rows.map((row, y) = > {
      return row.map((ch, x) = > {
        let type = levelChars[ch];
        if (typeof type == "string") return type;
        this.startActors.push(
          type.create(new Vec(x, y), ch));
        return "empty"; }); }); }}Copy the code

First we need to remove the Spaces at the beginning and end. Since our map is a two-dimensional model, we can parse it row by row, or column by column. Each time we encounter a newline, we push the current line into the array, resulting in an array of strings. By reading this array of strings, we know the height and width of the level.

We also need to distinguish between static and dynamic elements. Dynamic elements have additional attributes such as speed of movement, initial position, current position, and so on. So we need to define a class for each dynamic element (defined as Mary), and that class needs to have a static method to initialize the element to be added to startActors.

We also need to abstract every element on the map into code:

const levelChars = {
  ".": "empty"."#": "wall"."+": "lava"."@": Player,
  "o": Coin,
  "=": Lava,
  "|": Lava,
  "v": Lava
}
Copy the code

As the game progresses, Mary will end up in different places and even disappear (like coins). So we need persistent data.

class State {
  constructor(level, actors, status) {
    this.level = level;
    this.actors = actors;
    this.status = status;
  }

  static start(level) {
    return new State(level, level.startActors, 'playing')}get player() {
    return this.actors.find(a= > a.type === 'player'); }}Copy the code

When the game is over, the status value changes to win or Lost.

Definition of Mary

Mary represents the current position and state of moveable elements in the level. All Marys will have the same interface. The pos attribute represents the coordinate point relative to the upper-left corner (0,0), and size represents its size (width and height). They also have an UPDATE method that calculates the new state and position after a given current time step. For example, after the player presses the up, down, left, and right keys, a new position and state are returned.

class Vec {
  constructor(x, y) {
    this.x = x; this.y = y;
  }

  plus(other) {
    return new Vec(this.x + other.x, this.y + other.y);
  }

  times(factor) {
    return new Vec(this.x * factor, this.y * factor); }}Copy the code

Different Marys will have different manifestations, so there will need to be other ways of representing their different behaviors besides coordinates.

We also need to have a Type attribute that identifies Mary as Coin, Player, or Lava. Their respective sizes are set through the Type attribute.

  • Player players

    class Player {
      constructor(pos, speed) {
        this.pos = pos;
        this.speed = speed;
      }
    
      get type() {
        return 'player';
      }
    
      static create(pos) {
          return new Player(pos.plus(new Vec(0, -0.5)), new Vec(0.0))
      }
    }
    
    Player.prototype.size = new Vec(0.8.1.5);
    Copy the code

    A square in our game is 1*1, and we set the height of the player to be a half height square, so the y coordinate of the starting position is subtracted 0.5.

  • Lava Lava

    class Lava {
      constructor(pos, speed, reset) {
          this.pos = pos;
          this.speed = speed;
          this.reset = reset;
      }
    
      get type() {
        return "lava"
      }
    
      static create(pos, ch) {
        if(ch === '=') {
          return new Lava(pos, new Vec(2.0));
        } else if(ch === '|') {
          return new Lava(pos, new Vec(0.2));
        } else if (ch === 'v') {
          return new Lava(pos, new Vec(0.3, pos));
        }
      }
    }
    Lava.prototype.size = new Vec(1.1);
    Copy the code
  • “Gold Coin

    class Coin {
      constructor(pos, basePos, wobble) {
          this.pos = pos;
          this.basePos = basePos;
          this.wobble = wobble;
      }
    
      get type() {
        return 'coin'
      }
    
      static create(pos) {
        let basePos = pos.plus(new Vec(0.2.0.1));
        return new Coin(basePos, basePos, Math.random() * Math.PI * 2);
      }
    }
    
    Coin.prototype.size = new Vec(0.6.0.6)
    Copy the code

map

Now we can start drawing the map so that the elements within the level are statically displayed. We defined the scale of the elements in the map as 1:20. So we set the scale to 20. We need to store static elements separately from dynamic elements, because static elements are executed only once, and dynamic elements change all the time, so we define an actorLayer to store Mary.

function elt(name, attrs, ... children) {
  let dom = document.createElement(name);
  for (let attr of Object.keys(attrs)) {
    dom.setAttribute(attr, attrs[attr]);
  }
  for (let child of children) {
    dom.appendChild(child);
  }
  return dom;
}
Copy the code
class DOMDisplay {
  constructor(parent, level) {
    this.dom = elt("div", {class: "game"}, drawGrid(level));
    this.actorLayer = null;
    parent.appendChild(this.dom);
  }

  clear() { this.dom.remove(); }}Copy the code
const scale = 20;

function drawGrid(level) {
  return elt("table", {
    class: "background".style: `width: ${level.width * scale}px`
  }, ...level.rows.map(row= >
    elt("tr", {style: `height: ${scale}px`},
        ...row.map(type= > elt("td", {class: type})))
  ));
}
Copy the code

Now we just need to add styles for each static element to the stylesheet.

 .background    { background: rgb(52.166.251);
                table-layout: fixed;
                border-spacing: 0;              }
 .background td { padding: 0;                     }
 .lava          { background: rgb(255.100.100); }
 .wall          { background: white;              }
Copy the code

Now that our map is out, we’re going to start drawing Mary.

Draw Mary

function drawActors(actors) {
  return elt("div", {}, ...actors.map(actor= > {
    let rect = elt("div", {class: `actor ${actor.type}`});
    rect.style.width = `${actor.size.x * scale}px`;
    rect.style.height = `${actor.size.y * scale}px`;
    rect.style.left = `${actor.pos.x * scale}px`;
    rect.style.top = `${actor.pos.y * scale}px`;
    return rect;
  }));
}
Copy the code

We let these Marys off the hook with absolute positioning, and set different styles for different Marys.

.actor  { position: absolute;            }
.coin   { background: rgb(241.229.89); }
.player { background: rgb(64.64.64);  }
.lost .player {
   background: rgb(160.64.64);
}
.won .player {
  box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}
Copy the code

Since Mary is moving, we need to delete the old state every time we draw a new one.

DOMDisplay.prototype.syncState = function(state) {
  if (this.actorLayer) this.actorLayer.remove();
  this.actorLayer = drawActors(state.actors);
  this.dom.appendChild(this.actorLayer);
  this.dom.className = `game ${state.status}`;
};
Copy the code

Ok, at this point all the elements of our map have been drawn. Let’s run demo:

let simpleLevelPlan = `... . #... #.. . #... =. #.. . #... o.o.... #.. . #. @... # # # # #... #.. . # # # # #... #.. . # + + + + + + + + + + + + #.. . # # # # # # # # # # # # # #.. . `;

let simpleLevel = new Level(simpleLevelPlan);
let display = new DOMDisplay(document.body, simpleLevel);
display.syncState(State.start(simpleLevel));
Copy the code

Motion and collision

When we’re done drawing static elements, we need them to run, and we need them to interact. Since our current level won’t always fill the current screen view, the current map view will need to change as the Player moves. When the player is too close to the edge of the view, we need to set the element’s scrollLeft and scrollTop properties. Maybe some of you want to be lazy and just focus on the current player? There’s a problem with this, however, because as soon as the Player moves, the view moves, which makes it visually dizzy, so we just define the margin, the distance from the edge, and the view moves accordingly.

  DOMDisplay.prototype.scrollPlayerIntoView = function(state) {
    let width = this.dom.clientWidth;
    let height = this.dom.clientHeight;
    let margin = width / 3;

    // The viewport
    let left = this.dom.scrollLeft, right = left + width;
    let top = this.dom.scrollTop, bottom = top + height;

    let player = state.player;
    let center = player.pos.plus(player.size.times(0.5))
                          .times(scale);

    if (center.x < left + margin) {
      this.dom.scrollLeft = center.x - margin;
    } else if (center.x > right - margin) {
      this.dom.scrollLeft = center.x + margin - width;
    }
    if (center.y < top + margin) {
      this.dom.scrollTop = center.y - margin;
    } else if (center.y > bottom - margin) {
      this.dom.scrollTop = center.y + margin - height; }};Copy the code
 DOMDisplay.prototype.syncState = function(state) {
 	   // ...
     this.scrollPlayerIntoView(state);
 };
Copy the code

The most difficult part of this is dealing with Mary’s interactions. For example: the player hits a wall, he can’t walk through it, he has to stop moving. When the gold is eaten, it needs to disappear. If lava is encountered, declare the game lost and restart. But there are similar physics engines on the Internet that can help us with collisions in two or three dimensions.

We can do a simple collision detection mechanic: when we move the player or the lava moves, if the next step goes through the wall, we cancel the action.

Level.prototype.touches = function(pos, size, type) {
  var xStart = Math.floor(pos.x);
  var xEnd = Math.ceil(pos.x + size.x);
  var yStart = Math.floor(pos.y);
  var yEnd = Math.ceil(pos.y + size.y);

  for (var y = yStart; y < yEnd; y++) {
    for (var x = xStart; x < xEnd; x++) {
      let isOutside = x < 0 || x >= this.width ||
                      y < 0 || y >= this.height;
      let here = isOutside ? "wall" : this.rows[y][x];
      if (here == type) return true; }}return false;
};
Copy the code

Check if the player has touched lava by adding an update method to State.

State.prototype.update = function(time, keys) {
  let actors = this.actors
    .map(actor= > actor.update(time, this, keys));
  let newState = new State(this.level, actors, this.status);

  if(newState.status ! ="playing") return newState;

  let player = newState.player;
  if (this.level.touches(player.pos, player.size, "lava")) {
    return new State(this.level, actors, "lost");
  }

  for (let actor of actors) {
    if (actor != player && overlap(actor, player)) {
      newState = actor.collide(newState);
    }
  }
  return newState;
};
Copy the code

If the lava detects that it has been touched or the gold has been eaten, then we need to return the resulting defeat and victory.

Lava.prototype.collide = function(state) {
  return new State(state.level, state.actors, "lost");
};

Coin.prototype.collide = function(state) {
  let filtered = state.actors.filter(a= >a ! =this);
  let status = state.status;
  if(! filtered.some(a= > a.type == "coin")) status = "won";
  return new State(state.level, filtered, status);
};
Copy the code

Mary’s Update

With collision detection, we can draw the update operation for each Mary. Gold coins jump up and down, lava flows left and right up and down. Players can move freely.

  • Update of coins

    const wobbleSpeed = 8, wobbleDist = 0.07;
    
    Coin.prototype.update = function(time) {
      let wobble = this.wobble + time * wobbleSpeed;
      let wobblePos = Math.sin(wobble) * wobbleDist;
      return new Coin(this.basePos.plus(new Vec(0, wobblePos)),
                      this.basePos, wobble);
    };
    Copy the code
  • Renewal of lava

    The position of lava in the UPDATE method is determined by the time step and velocity. If there is no barrier (wall) at the next location, the lava moves to the new location. If there is an obstacle at the next position. Then its next state depends on the type of lava. For example, dripping lava meets an obstacle and starts again, while lava moving up and down multiplies its velocity by -1 and moves in the opposite direction.

    Lava.prototype.update = function(time, state) {
      let newPos = this.pos.plus(this.speed.times(time));
      if(! state.level.touches(newPos,this.size, "wall")) {
        return new Lava(newPos, this.speed, this.reset);
      } else if (this.reset) {
        return new Lava(this.reset, this.speed, this.reset);
      } else {
        return new Lava(this.pos, this.speed.times(-1)); }};Copy the code
  • Player updates

    const playerXSpeed = 7; / / speed
    const gravity = 30; // Gravity coefficient
    const jumpSpeed = 17; // Jump height
    
    Player.prototype.update = function(time, state, keys) {
      let xSpeed = 0;
      if (keys.ArrowLeft) xSpeed -= playerXSpeed;
      if (keys.ArrowRight) xSpeed += playerXSpeed;
      let pos = this.pos;
      let movedX = pos.plus(new Vec(xSpeed * time, 0));
      if(! state.level.touches(movedX,this.size, "wall")) {
        pos = movedX;
      }
    
      let ySpeed = this.speed.y + time * gravity;
      let movedY = pos.plus(new Vec(0, ySpeed * time));
      if(! state.level.touches(movedY,this.size, "wall")) {
        pos = movedY;
      } else if (keys.ArrowUp && ySpeed > 0) {
        ySpeed = -jumpSpeed;
      } else {
        ySpeed = 0;
      }
      return new Player(pos, new Vec(xSpeed, ySpeed));
    };
    Copy the code

Tracking buttons

When we have drawn all the Marys on the map, we need to have events that trigger them. So we need to listen to the d-pad on the keyboard to control the player’s movement.

function trackKeys(keys) {
  let down = Object.create(null);
  function track(event) {
    if (keys.includes(event.key)) {
      down[event.key] = event.type == "keydown"; event.preventDefault(); }}window.addEventListener("keydown", track);
  window.addEventListener("keyup", track);
  return down;
}

const arrowKeys =
  trackKeys(["ArrowLeft"."ArrowRight"."ArrowUp"]);
Copy the code

Let the game run

Define game entry execution functions

Let’s assume that the game has many levels. So define plans as a level array. If the current level passes, the next level is entered.

async function runGame(plans, Display) {
  for (let level = 0; level < plans.length;) {
    let status = await runLevel(new Level(plans[level]),
                                Display);
    if (status == "won") level++;
  }
  console.log("You've won!");
}
Copy the code
function runLevel(level, Display) {
  let display = new Display(document.body, level);
  let state = State.start(level);
  let ending = 1;
  return new Promise(resolve= > {
    runAnimation(time= > {
      state = state.update(time, arrowKeys);
      display.syncState(state);
      if (state.status == "playing") {
        return true;
      } else if (ending > 0) {
        ending -= time;
        return true;
      } else {
        display.clear();
        resolve(state.status);
        return false; }}); }); }function runAnimation(frameFunc) {
  let lastTime = null;
  function frame(time) {
    if(lastTime ! =null) {
      let timeStep = Math.min(time - lastTime, 100) / 1000;
      if (frameFunc(timeStep) === false) return;
    }
    lastTime = time;
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}

Copy the code

Ok! Our game is now over! Let’s run it

At the end

In the future, I plan to draw these abstract objects into real objects on canvas to enhance my experience! To be continued….

Next chapter addresses