This is part 3 of a special X-mas Ionic 4 tutorial. Please read part 1 and part 2 before continuing here…

Part 3: Make it move nicely

Animate the decoration

The easiest way to bring some animation into our app is to use Animate.css.

In the header section of our index.html we add:

  <!-- Animate -->
  <link rel="stylesheet" type="text/css" 
        href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.7.2/animate.min.css">

Now we switch to decoration.component.html and look for a few stars there. Search for the elements with the id “star1” and add a class attribute with the values “animated flash slower infinite delay-1s”. That means: animate this star with the flash effect at a slower speed and let this animation last indefinitely. Start the whole thing with a 1s delay.

<g id="star1" class="animated flash slower infinite delay-1s">
  <path class="cls-0" d="M256.4,175.4q0,36-.1,0c-.1,19.6-.3,26.6-.9,30.1-2.4-.4-4.9-2.8-9.2-7.1,4.2,4.1,6.5,6.7,7.1,8.9-3.8.7-11.2.8-33.1,1q39.3,0,0,.1c21.9.1,29.4.3,33.1.9-.5,2.3-2.9,4.9-7.1,9.1,4.3-4.3,6.8-6.7,9.2-7.2.6,3.5.8,10.3.9,30.4q.1-36,.1,0c.2-20,.3-26.8.9-30.3,2.3.6,4.8,3,8.9,7.1-4.2-4.2-6.6-6.7-7.1-9,3.7-.7,11-.9,33.2-1-26.2-.1-26.2-.2,0-.1-22.2-.2-29.5-.4-33.2-1,.6-2.3,2.9-4.8,7.1-8.9-4.1,4.1-6.6,6.4-8.9,7C256.7,201.9,256.6,194.9,256.4,175.4Z" transform="translate(0 -1)" />
    <path class="cls-0" d="M244.3,220.2l1.9-1.8Z" transform="translate(0 -1)" />
    <path class="cls-0" d="M244.3,196.5l1.9,1.9Z" transform="translate(0 -1)" />
    <path class="cls-0" d="M268,220.2l-1.8-1.8Z" transform="translate(0 -1)" />
    <path class="cls-0" d="M268,196.5l-1.8,1.9Z" transform="translate(0 -1)" />
</g>
 

Now add the same animation to “star2”, “star3”, “star4” and “star5”; change the delay to delay-2s, delay-3s, delay-4s and delay-5s.

Your Christmas decoration stars are now flashing great, aren’t they?

 

Let it snow

But what would Christmas be without snow? So let it snow!

In the assets folder create a file called snow.js with the following code:

// Amount of Snowflakes
var snowMax = 36;

// Snowflake Colours
var snowColor = ["#CCC", "#DDD"];

// Snow Entity
var snowEntity = "❄";

// Falling Velocity
var snowSpeed = 0.5;

// Minimum Flake Size
var snowMinSize = 12;

// Maximum Flake Size
var snowMaxSize = 24;

// Refresh Rate (in milliseconds)
var snowRefresh = 20;

// Additional Styles
var snowStyles = "cursor: default; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; -o-user-select: none; user-select: none;";

/*
// End of Configuration
// ----------------------------------------
// Do not modify the code below this line
*/

var snow = [],
	pos = [],
	coords = [],
	lefr = [],
	marginBottom,
	marginRight;

function randomise(range) {
	rand = Math.floor(range * Math.random());
	return rand;
}

function initSnow() {
	var snowSize = snowMaxSize - snowMinSize;
	marginBottom = document.body.scrollHeight - 5;
	marginRight = document.body.clientWidth - 15;

	for (i = 0; i <= snowMax; i++) {
		coords[i] = 0;
		lefr[i] = Math.random() * 15;
		pos[i] = 0.03 + Math.random() / 10;
		snow[i] = document.getElementById("flake" + i);
		snow[i].style.fontFamily = "inherit";
		snow[i].size = randomise(snowSize) + snowMinSize;
		snow[i].style.fontSize = snow[i].size + "px";
		snow[i].style.color = snowColor[randomise(snowColor.length)];
		snow[i].style.zIndex = 2000 + i;
		snow[i].sink = snowSpeed * snow[i].size / 5;
		snow[i].posX = randomise(marginRight - snow[i].size);
		snow[i].posY = randomise(2 * marginBottom - marginBottom - 2 * snow[i].size);
		snow[i].style.left = snow[i].posX + "px";
		snow[i].style.top = snow[i].posY + "px";
	}

	moveSnow();
}

function resize() {
	marginBottom = document.body.scrollHeight - 5;
	marginRight = document.body.clientWidth - 15;
}

function moveSnow() {
	for (i = 0; i <= snowMax; i++) {
		coords[i] += pos[i];
		snow[i].posY += snow[i].sink;
		snow[i].style.left = snow[i].posX + lefr[i] * Math.sin(coords[i] * .3) + "px";
		snow[i].style.top = snow[i].posY + "px";

		if (snow[i].posY >= marginBottom - 2 * snow[i].size || parseInt(snow[i].style.left) > (marginRight - 3 * lefr[i])) {
			snow[i].posX = randomise(marginRight - snow[i].size);
			snow[i].posY = 0;
		}
	}

	setTimeout("moveSnow()", snowRefresh);
}

for (i = 0; i <= snowMax; i++) {
	document.write("<span id='flake" + i + "' style='" + snowStyles + "position:absolute;top:-" + snowMaxSize + "'>" + snowEntity + "</span>");
}

window.addEventListener('resize', resize);
window.addEventListener('load', initSnow);
 

The snow.js code comes from the Australian programmer and designer Kurisu Brooks and can be found under the MIT license at https://www.cssscript.com/minimalist-falling-snow-effect-with-pure-javascript-snow-js/. I modified it a little for our app.

We embed the script – as before the reference to Animate.css – in the header area of our index.html:

<!-- Snow -->
<script src="assets/snow.js"></script>
 

That’s it! Now it snows.

 

Moving Santa

Now to Santa! We want to use the arrow keys and the letters W, A, S and D to move him through the maze.

Think of the maze as a grid with 16×16 cells. When navigating, each cell must be checked to determine whether it has a wall on the left, top, right or bottom. Only when there’s no wall Santa can turn left, go upstairs, right or downstairs.

To implement this logic, we create a maze service.

In the terminal we enter the following:

$ ionic g service services/maze

The Ionic CLI then generates a services folder and a file called maze.service.ts in it, which we edit as follows:

import { Injectable } from '@angular/core';
import { MazeCell } from 'src/app/models/maze-cell';
import { MazePosition } from 'src/app/models/maze-position';

@Injectable({
  providedIn: 'root'
})
export class MazeService {

  public Rows: number;
  public Cols: number;
  public CellPixels: number;

  private cellMatrix = [[]];

  constructor() { }

  public Init(rows: number, cols: number) {
    this.cellMatrix = this.getCellMatrix(rows, cols);
  }

  private getCellMatrix(rows: number, cols: number) {

    const svg = document.getElementById('maze-svg');
    const viewBox = svg.getAttribute('viewBox');
    const viewBoxPixels: number = parseFloat(viewBox.split(' ')[2]) - 1;
    const maze = document.getElementById('maze');
    this.Rows = rows;
    this.Cols = cols;
    this.CellPixels = viewBoxPixels / this.Rows;

    let cellMatrix = this.getEmptyCellMatrix(this.Rows, this.Cols);

    // Loop over all line elements (children).
    for (let i = 0; i < maze.children.length; i++) {

      // Grab line elements
      let x1 = parseFloat(maze.children[i].getAttribute('x1'));
      let x2 = parseFloat(maze.children[i].getAttribute('x2'));
      let y1 = parseFloat(maze.children[i].getAttribute('y1'));
      let y2 = parseFloat(maze.children[i].getAttribute('y2'));

      // Starting position
      let row: number = Math.round(y1 / this.CellPixels);
      let col: number = Math.round(x1 / this.CellPixels);

      let isVerticalLine: boolean = (x1 == x2);
      let isHorizontalLine: boolean = (y1 == y2);

      // Vertical line
      if (isVerticalLine) {

        let rows: number = Math.round((y2 - y1) / this.CellPixels);
        let isFirstCol: boolean = (col == 0);
        let isLastCol: boolean = (col == this.Cols);

        // Set 'left' in current col.
        if (!isLastCol) {
          for (let r = row; r < row + rows; r++) {
            let cell: MazeCell = cellMatrix[r][col];
            cell.BorderLeft = 1;
          }
        }

        // Set 'right' in previous col.
        if (!isFirstCol) {
          for (let r = row; r < row + rows; r++) {
            let cell: MazeCell = cellMatrix[r][col - 1];
            cell.BorderRight = 1;
          }
        }

      }

      // Horizontal line
      if (isHorizontalLine) {

        let cols: number = Math.round((x2 - x1) / this.CellPixels);
        let isFirstRow: boolean = (row == 0);
        let isLastRow: boolean = (row == this.Cols);

        // Set 'top' in current row.
        if (!isLastRow) {
          for (let c = col; c < col + cols; c++) {
            let cell: MazeCell = cellMatrix[row];
            cell.BorderTop = 1;
          }
        }

        // Set 'bottom' in previous row.
        if (!isFirstRow) {
          for (let c = col; c < col + cols; c++) {
            let cell: MazeCell = cellMatrix[row - 1];
            cell.BorderBottom = 1;
          }
        }

      }

    } // End loop over all line elements.

    return cellMatrix;

  }

  private getEmptyCellMatrix(rows: number, cols: number): any {
    let cellMatrix = [[]];
    for (let r = 0; r < rows; r++) {
      cellMatrix[r] = [];
      for (let c = 0; c < cols; c++) {
        cellMatrix[r] = new MazeCell();
      }
    }
    return cellMatrix;
  }

  public CanMoveLeft(pos: MazePosition) {
    var cell: MazeCell = this.cellMatrix[pos.Row][pos.Col];
    return cell.BorderLeft == 0;
  }

  public CanMoveUp(pos: MazePosition) {
    var cell: MazeCell = this.cellMatrix[pos.Row][pos.Col];
    return cell.BorderTop == 0;
  }

  public CanMoveRight(pos: MazePosition) {
    var cell: MazeCell = this.cellMatrix[pos.Row][pos.Col];
    return cell.BorderRight == 0;
  }

  public CanMoveDown(pos: MazePosition) {
    var cell: MazeCell = this.cellMatrix[pos.Row][pos.Col];
    return cell.BorderBottom == 0;
  }

}
 

Regarding the code: We take advantage of the fact that the SVG graphic encodes the “cell walls” as line elements (see our maze.component.html). We can identify these as vertical or horizontal “walls” reading the attributes x1, x2, y1, y2. In this way, we summarize the maze in a structured manner as CellMatrix. On this basis, our service can now provide the public methods CanMoveLeft, CanMoveUp, CanMoveRight and CanMoveDown.

What is still missing are the model classes MazePosition and MazeCell imported in lines 2 and 3.

Here the code of app/models/maze-cell.ts:

export class MazeCell {

    public BorderLeft: number = 0;
    public BorderTop: number = 0;
    public BorderRight: number = 0;
    public BorderBottom: number = 0;

    constructor(bLeft?: number, bTop?: number, bRight?: number, bBottom?: number) {

        if (bLeft != null) {
            this.BorderLeft = bLeft;
        }
        if (bTop != null) {
            this.BorderTop = bTop;
        }
        if (bRight != null) {
            this.BorderRight = bRight;
        }
        if (bBottom != null) {
            this.BorderBottom = bBottom;
        }
        
    }

}
 


And here the code of app/models/maze-position.ts:

export class MazePosition {

    public Row: number;
    public Col: number; 

    constructor(row: number, col: number) {
        this.Row = row;
        this.Col = col;
    }

}
 

Last but not least, we now have to teach Santa to walk. For this we create the GameService.

In the terminal we enter the following:

$ ionic g service services/game

The Ionic CLI then generates a file called game.service.ts in the services folder and , which we edit as follows:

import { Injectable } from '@angular/core';
import { MazeService } from '../services/maze.service';
import { MazePosition } from 'src/app/models/maze-position';
import { ToastController } from '@ionic/angular';

@Injectable({
  providedIn: 'root'
})
export class GameService {

  public status: string;

  /** player: position */
  private playerPosition: MazePosition = new MazePosition(0, 7);

  /** items: positions */
  private itemPresent1 = new MazePosition(7, 1);
  private itemPresent2 = new MazePosition(8, 15);
  private itemPresent3 = new MazePosition(12, 8);

  /** items: collected */
  private hasPresent1: boolean = false;
  private hasPresent2: boolean = false;
  private hasPresent3: boolean = false;

  constructor(
    private maze: MazeService,
    private toastController: ToastController,
  ) { }

  /**
   * Starts the game.
   */
  public async Start() {
    this.maze.Init(16, 16);
    this.initPlayer();
  }

  /**
   * Navigates with the arrow keys or WASD.
   * 
   * @param keydownEvent.code event code coming from keydown
   */
  public Navigate(keypressEvent) {
    switch (keypressEvent) {
      case 'ArrowUp':
      case 'KeyW':
        if (this.maze.CanMoveUp(this.playerPosition)) {
          this.movePlayer(this.playerPosition.Col, this.playerPosition.Row - 1);
        }
        break;
      case 'ArrowLeft':
      case 'KeyA':
        if (this.maze.CanMoveLeft(this.playerPosition)) {
          this.movePlayer(this.playerPosition.Col - 1, this.playerPosition.Row);
        } else {
        }
        break;
      case 'ArrowDown':
      case 'KeyS':
        if (this.maze.CanMoveDown(this.playerPosition)) {
          this.movePlayer(this.playerPosition.Col, this.playerPosition.Row + 1);
        }
        break;
      case 'ArrowRight':
      case 'KeyD':
        if (this.maze.CanMoveRight(this.playerPosition)) {
          this.movePlayer(this.playerPosition.Col + 1, this.playerPosition.Row);
        }
        break;
    }
  }

  private initPlayer() {
    this.movePlayer(this.playerPosition.Col, this.playerPosition.Row);
  }

  private movePlayer(col: number, row: number) {
    if (row < 0) {
      this.presentToast('Hey!', 'What kind of unreliable Santa are you?');
    }
    else if (row < this.maze.Rows) {
      this.moveSection('santa',
        col * (this.maze.CellPixels),
        row * (this.maze.CellPixels)
      );
      this.playerPosition = new MazePosition(row, col);
      this.checkForItemHits();
    } else {
      if (!this.hasPresent1 || !this.hasPresent2 || !this.hasPresent3) {
        this.presentToast('Hey!', 'You haven\'t found all the presents yet, Santa!');
      } else {
        this.presentToast('Ho ho ho!', 'You found all the presents, Santa!\nChristmas can come now ;-)');
      }
    }
  }

  private moveSection(elementName: string, xOffset, yOffset) {
    var element = document.getElementById(elementName);
    if (element) {
      var transformAttr = ' translate(' + xOffset + ',' + yOffset + ')';
      element.setAttribute('transform', transformAttr);
    }
  }

  private checkForItemHits() {
    console.log(this.playerPosition.Col, this.playerPosition.Row, '-', this.itemPresent1.Col, this.itemPresent1.Row);
    if (this.playerPosition.Col == this.itemPresent1.Col
      &amp;&amp; this.playerPosition.Row == this.itemPresent1.Row) {
      this.hasPresent1 = true;
      this.presentToast('Hey!', 'You\'ve found present 1, Santa!');
      this.hide('present1');
    }
    if (this.playerPosition.Col == this.itemPresent2.Col
      &amp;&amp; this.playerPosition.Row == this.itemPresent2.Row) {
      this.hasPresent2 = true;
      this.presentToast('Hey!', 'You\'ve found present 2, Santa!');
      this.hide('present2');
    }
    if (this.playerPosition.Col == this.itemPresent3.Col
      &amp;&amp; this.playerPosition.Row == this.itemPresent3.Row) {
      this.hasPresent3 = true;
      this.presentToast('Hey!', 'You\'ve found present 3, Santa!');
      this.hide('present3');
    }
  }

  private hide(elementName: string) {
    var element = document.getElementById(elementName);
    element.style.display = "none";
  }

  private async presentToast(header: string, message: string) {
    const toast = await this.toastController.create({
      header: header,
      message: message,
      duration: 5000,
      position: 'top'
    });
    await toast.present();
  }

}
 

In GameService we set the position of Santa and the three presents. In the Start() method we initialize the maze and the player (Santa). The Navigate() method ensures the correct movements of Santa. When Santa finds a gift, the checkForHits() method detects it and issues a toast. A found gift is hidden using the hide() method.

We have to bind the buttons to the GameService so that the navigation works. To do this, we first open arrow-buttons.component.ts and import and inject the service:

import { Component, OnInit } from '@angular/core';
import { GameService } from 'src/app/services/game.service';

@Component({
  selector: 'app-arrow-buttons',
  templateUrl: './arrow-buttons.component.html',
  styleUrls: ['./arrow-buttons.component.scss'],
})
export class ArrowButtonsComponent implements OnInit {

  public isPortrait: boolean;

  constructor(public game: GameService) { }

  ngOnInit() {
    this.checkOrientation();
  }

  private checkOrientation() {
    var mq = window.matchMedia("(orientation: portrait)");
    this.isPortrait = mq.matches;
    mq.addListener(m => {
      this.isPortrait = m.matches;
    });
  }

}
 

At the end we have to equip all buttons in arrow-buttons.component.html with (click) events:

<!-- Button group for portrait mode -->
<ion-grid id="buttons-portrait" *ngIf="isPortrait">
  <ion-row>
    <ion-col size="12">
      <ion-button expand="block" (click)="game.Navigate('KeyW')">
        <ion-icon name="md-arrow-round-up"></ion-icon>
      </ion-button>
    </ion-col>
  </ion-row>
  <ion-row>
    <ion-col size="6">
      <ion-button expand="block" (click)="game.Navigate('KeyA')">
        <ion-icon name="md-arrow-round-back"></ion-icon>
      </ion-button>
    </ion-col>
    <ion-col size="6">
      <ion-button expand="block" (click)="game.Navigate('KeyD')">
        <ion-icon name="md-arrow-round-forward"></ion-icon>
      </ion-button>
    </ion-col>
  </ion-row>
  <ion-row>
    <ion-col size="12">
      <ion-button expand="block" (click)="game.Navigate('KeyS')">
        <ion-icon name="md-arrow-round-down"></ion-icon>
      </ion-button>
    </ion-col>
  </ion-row>
</ion-grid>

<!-- Button group for landscape mode -->
<ion-grid id="buttons-landscape" *ngIf="!isPortrait">
  <ion-row>
    <ion-col size="2">
      <ion-button expand="block" (click)="game.Navigate('KeyW')">
        <ion-icon name="md-arrow-round-up"></ion-icon>
      </ion-button>
    </ion-col>
    <ion-col size="8"></ion-col>
    <ion-col size="2">
      <ion-button expand="block" (click)="game.Navigate('KeyS')">
        <ion-icon name="md-arrow-round-down"></ion-icon>
      </ion-button>
    </ion-col>
  </ion-row>
  <ion-row>
    <ion-col size="2">
      <ion-button expand="block" (click)="game.Navigate('KeyA')">
        <ion-icon name="md-arrow-round-back"></ion-icon>
      </ion-button>
    </ion-col>
    <ion-col size="8"></ion-col>
    <ion-col size="2">
      <ion-button expand="block" (click)="game.Navigate('KeyD')">
        <ion-icon name="md-arrow-round-forward"></ion-icon>
      </ion-button>
    </ion-col>
  </ion-row>
</ion-grid>
 

Next time we’ll look at how we can get the letter keys W, A, S, and D to make Santa’s legs… Oh, and Christmas music comes along…

I hope you enjoyed this tutorial so far and you will be back on the 4th and last part next Sunday.

Happy reading and coding!

Leave a comment

Your email address will not be published. Required fields are marked *