CSS. Unidad 4. Aplicando estilos a listas.

Introducción

Las listas se comportan como cualquier otro texto en su mayor parte, pero hay algunas propiedades CSS específicas de las listas que debes conocer y algunas recomendaciones a tener en cuenta. Este artículo te lo explica.

Un ejemplo sencillo de lista

Para empezar, veamos un ejemplo sencillo de una lista. A lo largo de esta unidad veremos listas no ordenadas, listas ordenadas y listas de descripciones. Todas tienen características de estilo similares, pero algunas propiedades son particulares del tipo de lista.

Utilizaremos el siguiente código HTML:

<h2>Ingredienetes (lista no ordenada)</h2>

<ul>
  <li>Hummus</li>
  <li>Pan pita</li>
  <li>Ensalada verde</li>
  <li>Queso halloumi</li>
</ul>

<h2>Ingredientes (lista descriptiva)</h2>

<dl>
  <dt>Hummus</dt>
  <dd>Una salsa espesa hecha generalmente de garbanzos, tahini, jugo de limón, sal, ajo y otros ingredientes.</dd>
  <dt>Pan pita</dt>
  <dd>Un pan plano suave y ligeramente leudado.</dd>
  <dt>Queso halloumi</dt>
  <dd>Un queso semiduro, sin madurar, en salmuera con un punto de fusión superior al habitual, generalmente hecho de leche de cabra/oveja.</dd>
  <dt>Ensalada verde</dt>
  <dd>Esa cosa verde y saludable que muchos de nosotros solo usamos para adornar kebabs.</dd>
</dl>

<h2>Receta (lista ordenada)</h2>

<ol>
  <li>Tostar el pan pita, dejar enfriar y cortar por el borde.</li>
  <li>Freír el queso halloumi en una sartén antiadherente poco profunda hasta que esté dorado por ambos lados.</li>
  <li>Lavar y picar la ensalada.</li>
  <li>Rellenar el pan pita con ensalada, hummus y queso halloumi frito.</li>
</ol>

Los valores predeterminados son los siguientes:

  • Los elementos <ul> y <ol> tienen un margin superior e inferior de 16px (1em) y un padding-left de 40px (2.5em.)
  • Los elementos de lista <li> no tienen valores de espacio predeterminados.
  • El elemento <dl> tiene un margin superior e inferior de 16px (1em), pero no tiene ningún padding establecido.
  • Los elementos <dd> tienen un margin-left de 40px (2.5em).
  • Los elementos de referencia <p> que hemos incluido tienen un margin superior e inferior de 16px (1em), al igual que los diferentes tipos de lista.

Ejercicio propuesto: Listas con los estilos por defecto

Crea una página web con la lista del ejemplo anterior y observa el resultado en tu navegador. Se debería mostrar con los estilos por defecto, ya que no estamos utilizando CSS todavía.

Deberías obtener un resultado parecido al siguiente:

Ingredientes (lista no ordenada)

  • Hummus
  • Pan pita
  • Ensalada verde
  • Queso halloumi

Ingredientes (lista descriptiva)

Hummus
Una salsa espesa hecha generalmente de garbanzos, tahini, jugo de limón, sal, ajo y otros ingredientes.
Pan pita
Un pan plano suave y ligeramente leudado.
Queso halloumi
Un queso semiduro, sin madurar, en salmuera con un punto de fusión superior al habitual, generalmente hecho de leche de cabra/oveja.
Ensalada verde
Esa cosa verde y saludable que muchos de nosotros solo usamos para adornar kebabs.

Receta (lista ordenada)

  1. Tostar el pan pita, dejar enfriar y cortar por el borde.
  2. Freír el queso halloumi en una sartén antiadherente poco profunda hasta que esté dorado por ambos lados.
  3. Lavar y picar la ensalada.
  4. Rellenar el pan pita con ensalada, hummus y queso halloumi frito.

Estilos básicos de las listas

Al crear listas, es necesario ajustar el diseño para que mantengan los mismos espaciados verticales (a veces denominados ritmos verticales) que el resto de elementos circundantes, como párrafos e imágenes.

El CSS que vamos a utilizar para aplicar estilo al texto y al espaciado de texto es el siguiente:

/* Estilos generales */
html {
  font-family: Helvetica, Arial, sans-serif;
  font-size: 10px;
}

h2 {
  font-size: 2rem;
}

ul, ol, dl, p {
  font-size: 1.5rem;
}

li, p {
  line-height: 1.5;
}

/* Estilos para las listas de descripciones */
dd, dt {
  line-height: 1.5;
}

dt {
  font-weight: bold;
}

dd {
  margin-bottom: 1.5rem;
}
  • La primera regla establece un tipo de letra y un tamaño de letra base de 10px. Estos valores se heredan para toda la página.
  • Las reglas 2 y 3 establecen tamaños de letra relativos para los títulos, diferentes tipos de listas (que heredan los hijos de los elementos de listas), y párrafos. Esto significa que todos los párrafos y todas las listas tendrán el mismo tamaño de letra y el mismo espaciado superior e inferior, lo que ayudará a mantener el ritmo vertical constante.
  • La regla 4 establece el mismo interlineado (line-height) en los párrafos y los elementos de las listas, de modo que todos los párrafos y todos los elementos individuales de las listas tendrán el mismo espaciado entre las líneas. Esto también ayudará a mantener el ritmo vertical consistente.
  • Las reglas 5 y 6 se aplican a las listas de descripciones. Establecemos la misma altura de interlineado (line-height) en los términos y las descripciones de la lista de descripciones, así como hicimos con los párrafos y los elementos de la lista. De esta forma mantenemos un estilo coherente. También establecemos que los términos de las descripciones tengan un estilo de negrita, para que destaquen visualmente.

Ejercicio propuesto: Listas con tus propios estilos

Crea una página web con las listas y estilos del ejemplo anterior, y cambia o añade los estilos que quieras. A continuación enlaza el fichero CSS desde el fichero HTML, y comprueba el resultado en tu navegador. No olvides validar tanto el código HTML como el CSS.

El resultado obtenido podría ser similar al siguiente:

Ingredientes (lista no ordenada)

  • Hummus
  • Pan pita
  • Ensalada verde
  • Queso halloumi

Ingredientes (lista descriptiva)

Hummus
Una salsa espesa hecha generalmente de garbanzos, tahini, jugo de limón, sal, ajo y otros ingredientes.
Pan pita
Un pan plano suave y ligeramente leudado.
Queso halloumi
Un queso semiduro, sin madurar, en salmuera con un punto de fusión superior al habitual, generalmente hecho de leche de cabra/oveja.
Ensalada verde
Esa cosa verde y saludable que muchos de nosotros solo usamos para adornar kebabs.

Receta (lista ordenada)

  1. Tostar el pan pita, dejar enfriar y cortar por el borde.
  2. Freír el queso halloumi en una sartén antiadherente poco profunda hasta que esté dorado por ambos lados.
  3. Lavar y picar la ensalada.
  4. Rellenar el pan pita con ensalada, hummus y queso halloumi frito.

Estilos específicos de listas

Ahora que hemos analizado el espaciado general de las listas, exploremos algunas propiedades específicas de las listas. Para empezar, debes conocer tres propiedades que pueden establecerse en los elementos <ul> o <ol>:

  • list-style-type: Establece el tipo de viñetas para la lista, por ejemplo, viñetas cuadradas o circulares para una lista no ordenada; números, letras, o números romanos para una lista ordenada.
  • list-style-position: Establece si las viñetas aparecen dentro de los elementos de la lista o fuera de ellos, antes del inicio de cada elemento.
  • list-style-image: Te permite usar una imagen personalizada para la viñeta, en lugar de un simple cuadrado o círculo.

El estilo de la viñeta

Como hemos comentado, la propiedad list-style-type te permite establecer qué tipo de viñeta usar. En nuestro ejemplo, hemos establecido que se usen números romanos en mayúsculas para la lista ordenada, con:

ol {
  list-style-type: upper-roman;
}
  1. Tostar el pan pita, dejar enfriar y cortar por el borde.
  2. Freír el queso halloumi en una sartén antiadherente poco profunda hasta que esté dorado por ambos lados.
  3. Lavar y picar la ensalada.
  4. Rellenar el pan pita con ensalada, hummus y queso halloumi frito.

Puedes encontrar más opciones si echas un vistazo a la página de referencia de list-style-type.

La posición de la viñeta

La propiedad list-style-position establece si las viñetas aparecen dentro de los elementos de la lista, o fuera de ellos antes del inicio de cada elemento. El valor por defecto es outside, que provoca que las viñetas se sitúen fuera de los elementos de lista, como se observa arriba.

Si estableces el valor en inside, las viñetas se ubican dentro de las líneas:

ol {
  list-style-type: upper-roman;
  list-style-position: inside;
}
  1. Tostar el pan pita, dejar enfriar y cortar por el borde.
  2. Freír el queso halloumi en una sartén antiadherente poco profunda hasta que esté dorado por ambos lados.
  3. Lavar y picar la ensalada.
  4. Rellenar el pan pita con ensalada, hummus y queso halloumi frito.

Usando una imagen personalizada como viñeta

La propiedad list-style-image te permite usar una imagen personalizada para tu viñeta. La sintaxis es muy simple:

ul {
  list-style-image: url("https://mdn.github.io/learning-area/css/styling-text/styling-lists/star.svg");
}

Sin embargo, esta propiedad es un poco limitada por lo que respecta al control de la posición, el tamaño, etc., de las viñetas. En algunos casos puede resultar más conveniente usar la propiedad background, tal como haremos nosotros. En nuestro ejemplo vamos a definir el estilo a la lista no ordenada utilizando esta última propiedad. Para ello añadiremos el siguiente código al que ya teníamos previamente:

ul {
  padding-left: 2rem;
  list-style-type: none;
}

ul li {
  padding-left: 2rem;
  background-image: url("https://mdn.github.io/learning-area/css/styling-text/styling-lists/star.svg");
  background-position: 0 0;
  background-size: 1.6rem 1.6rem;
  background-repeat: no-repeat;
}
  • Hummus
  • Pan pita
  • Ensalada verde
  • Queso halloumi

Aquí hemos hecho lo siguiente:

  • Reducir el valor de la propiedad padding-left del elemento <ul> desde su valor predeterminado de 40 px hasta 20 px. A continuación, establecer la misma cantidad para los elementos de la lista. De este modo, todos los elementos de la lista siguen alineados con los elementos de la lista ordenada y las descripciones, pero los elementos de lista tienen algo de relleno (padding) para poder insertar las imágenes de fondo. Si no hiciéramos esto, las imágenes de fondo se solaparían con el texto de los elementos de la lista y quedaría un aspecto desordenado.
  • Establecer la propiedad list-style-type en none, para que no aparezca la viñeta predeterminada. En lugar de ello, vamos a utilizar las propiedades background para cambiar las viñetas.
  • Insertar una viñeta en cada elemento de la lista no ordenada. Las propiedades relevantes son las siguientes:
    • background-image: Proporciona la ruta que apunta al archivo de imagen que quieres usar como viñeta.
    • background-position: Define en qué lugar del elemento seleccionado va a aparecer la imagen; en este caso le decimos 0 0, que significa que la viñeta va a aparecer en el extremo superior izquierdo de cada elemento de lista.
    • background-size: Establece el tamaño de la imagen de fondo. En teoría queremos que las viñetas sean del mismo tamaño que los elementos de lista (o solo un poco menores o mayores). Utilizamos un tamaño de 1.6rem (16px), que encaja muy bien con el área de relleno de 20px que hemos elegido para que quepa la viñeta; 16 px más 4 px de espacio entre la viñeta y el texto del elemento de lista funciona bien.
    • background-repeat: Por defecto, las imágenes de fondo se repiten hasta rellenar todo el espacio de fondo disponible. En este caso solo queremos una copia de la imagen, de modo que establecemos el valor de esta propiedad en no-repeat.

Ejercicio propuesto: Juntándolo todo

Utilizando el código del ejercicio anterior, cambia las viñetas que aparecen por defecto para utilizar una imagen, y cambia también la numeración por defecto para utilizar cualquier otro estilo que te guste. Finalmente comprueba el resultado en tu navegador y valida el código HTML y CSS.

Un posible resultado podría ser el siguiente:

Ingredientes (lista no ordenada)

  • Hummus
  • Pan pita
  • Ensalada verde
  • Queso halloumi

Ingredientes (lista descriptiva)

Hummus
Una salsa espesa hecha generalmente de garbanzos, tahini, jugo de limón, sal, ajo y otros ingredientes.
Pan pita
Un pan plano suave y ligeramente leudado.
Queso halloumi
Un queso semiduro, sin madurar, en salmuera con un punto de fusión superior al habitual, generalmente hecho de leche de cabra/oveja.
Ensalada verde
Esa cosa verde y saludable que muchos de nosotros solo usamos para adornar kebabs.

Receta (lista ordenada)

  1. Tostar el pan pita, dejar enfriar y cortar por el borde.
  2. Freír el queso halloumi en una sartén antiadherente poco profunda hasta que esté dorado por ambos lados.
  3. Lavar y picar la ensalada.
  4. Rellenar el pan pita con ensalada, hummus y queso halloumi frito.

Propiedad abreviada list-style

Es importante mencionar la existencia de la propiedad abreviada list-style, que nos permite configurar las tres propiedades del ejemplo anterior en una sola línea. Por ejemplo, observa el CSS siguiente:

ul {
  list-style-type: disc;
  list-style-image: url(example.png);
  list-style-position: inside;
}

Se podría reemplazarse por esto:

ul {
  list-style: disc url(example.png) inside;
}

Los valores pueden escribirse en cualquier orden, y puedes usar uno, dos o los tres (los valores por defecto que se utilizan para las propiedades que no están incluidas son discnone y outside). Si se especifican tanto type como image, el valor de type se usa como una segunda opción en el caso de que no sea posible cargar la imagen por cualquier motivo.

Test

Comprueba tus conocimientos con este test sobre estilos de listas.

MS Paint remake

Classic MS Paint, REVIVED + ✨Extras

A pixel-perfect web-based MS Paint remake and more. JS Paint recreates every tool and menu of MS Paint to a high degree of fidelity:

It supports even little-known features, themes, additional file types, and accessibility features like Eye Gaze Mode and Speech Recognition (more information on github).

Ah yes, good old Paint. Not the one with the ribbons or the new skeuomorphic one with the interface that can take up nearly half the screen. (And not the even newer Paint 3D.)

Windows 95, 98, and XP were the golden years of Paint. You had a tool box and a color box, a foreground color and a background color, and that was all you needed.

Things were simple. But we want to undo more than three actions. We want to edit transparent images. We can’t just keep using the old Paint. So that’s why the author is making JS Paint. He wants to bring good old Paint into the modern era.

Current improvements

  • Open source (MIT licensed)
  • Cross-platform
  • Mobile friendly
    • Touch support: use two fingers to pan the view, and pinch to zoom
    • Click/tap the selected colors area to swap the foreground and background colors
    • View > Fullscreen to toggle fullscreen mode, nice for small screens
  • Web features
    • File > Load From URL… to open an image from the Web.
    • File > Upload to Imgur to upload the current image to Imgur.
    • Paste supports loading from URLs.
    • You can create links that will open an image from the Web in JS Paint. For example, this link will start with an isometric grid as a template: https://paint.fernandoruizrico.com/#load:https://i.imgur.com/zJMrWwb.png
    • Rudimentary multi-user collaboration support. Start up a session at https:/paint.fernandoruizrico.com/#session:multi-user-test and send the link to your friends! It isn’t seamless; actions by other users interrupt what you’re doing, and visa versa. Sessions are not private, and you may lose your work at any time. If you want better collaboration support, follow the development of Mopaint.
  • Extras > Themes to change the look of the app. Dark mode included.
  • Eye Gaze Mode, for use with an eye tracker, head tracker, or other coarse input device, accessible from Extras > Eye Gaze Mode. With just a webcam, you can try it out with Enable Viacam (head tracker) or GazePointer (eye tracker).
  • Speech Recognition Mode. Using your voice you can select tools and colors, pan the view (“scroll down and to the left”, or “go southwest”, etc.), explore the menus (but you can activate any menu item without opening the menus first), interact with windows (including scrolling the history view with “scroll up”/”scroll down” etc.), dictate text with the Text tool, and even tell the application to sketch things (for instance, “draw a house”)
  • Create an animated GIF from the current document history. Accessible from the Extras menu or with Ctrl+Shift+G. It’s pretty nifty, you should try it out! You might want to limit the size of the image though.
  • Load and save many different palette formats with Colors > Get Colors and Colors > Save Colors. (I made a library for this: AnyPalette.js.)
    • You can also drag and drop palette files into the app to load.

Editing Features

  • Use Alt+Mousewheel to zoom in and out
  • Edit transparent images! To create a transparent image, go to Image > Attributes… and select Transparent, then OK, and then Image > Clear Image or use the Eraser tool. Images with any translucent pixels will open in Transparent mode.
  • You can crop the image by making a selection while holding Ctrl
  • Keyboard shortcuts for rotation: Ctrl+. and Ctrl+, (< and >)
  • Rotate by any arbitrary angle in Image > Flip/Rotate
  • In Image > Stretch/Skew, you can stretch more than 500% at once
  • Zoom to an arbitrary scale in View > Zoom > Custom…
  • Zoom to fit the canvas within the window with View > Zoom > Zoom To Window
  • Non-contiguous fill: Replace a color in the entire image by holding Shift when using the fill tool

Miscellaneous Improvements

  • Vertical Color Box mode, accessible from Extras > Vertical Color Box
  • You can use the Text tool at any zoom level (and it previews the exact pixels that will end up on the canvas).
  • Spellcheck is available in the textbox if your browser supports it.
  • Resize handles are easier to grab than in Windows 10’s Paint.
  • Omits some Thumbnail view bugs, like the selection showing in the wrong place.
  • Unlimited undos/redos (as opposed to a measly 3 in Windows XP, or a measly 50 in Windows 7)
  • Undo history is nonlinear, which means if you undo and do something other than redo, the redos aren’t discarded. Instead, a new branch is created in the history tree. Jump to any point in history with Edit > History or Ctrl+Shift+Y
  • Automatically keeps a backup of your image. Only one backup per image tho, which doesn’t give you a lot of safety. Remember to save with File > Save or Ctrl+S! Manage backups with File > Manage Storage.
JS Paint drawing of JS Paint on a phone

Limitations

A few things with the tools aren’t done yet. See TODO.md

Full clipboard support in the web app requires a browser supporting the Async Clipboard API w/ Images, namely Chrome 76+ at the time of writing.

In other browsers you can still can copy with Ctrl+C, cut with Ctrl+X, and paste with Ctrl+V, but data copied from JS Paint can only be pasted into other instances of JS Paint. External images can be pasted in.

Supported File Formats

Image Formats

⚠️ Saving as JPEG will introduce artifacts that cause problems when using the Fill tool or transparent selections.

⚠️ Saving in some formats will reduce the number of colors in the image.

💡 Unlike in MS Paint, you can use Edit > Undo to revert color or quality reduction from saving. This doesn’t undo saving the file, but allows you to then save in a different format with higher quality, using File > Save As.

💡 Saving as PNG is recommended as it gives small file sizes while retaining full quality.

File ExtensionNameReadWriteRead PaletteWrite Palette
.pngPNG🔜
.bmp, .dibMonochrome Bitmap🔜
.bmp, .dib16 Color Bitmap🔜
.bmp, .dib256 Color Bitmap🔜
.bmp, .dib24-bit BitmapN/AN/A
.tif, .tiff, .dng, .cr2, .nefTIFF (loads first page)
.pdfPDF (loads first page)
.webpWebP🌐🌐
.gifGIF🌐🌐
.jpeg, .jpgJPEG🌐🌐N/AN/A
.svgSVG (only default size)🌐
.icoICO (only default size)🌐

Capabilities marked with 🌐 are currently left up to the browser to support or not. If “Write” is marked with 🌐, the format will appear in the file type dropdown but may not work when you try to save. For opening files, see Wikipedia’s browser image format support table for more information.

Capabilities marked with 🔜 are coming soon, and N/A of course means not applicable.

“Read Palette” refers to loading the colors into the Colors box automatically (from an indexed color image), and “Write Palette” refers to writing an indexed color image.

Color Palette Formats

With Colors > Save Colors and Colors > Get Colors you can save and load colors in many different formats, for compatibility with a wide range of programs.

If you want to add extensive palette support to another application, I’ve made this functionality available as a library:  AnyPalette.js

File ExtensionNameProgramsReadWrite
.palRIFF PaletteMS Paint for Windows 95 and Windows NT 4.0
.gplGIMP PaletteGimpInkscapeKritaKolourPaintScribusCinePaintMyPaint
.acoAdobe Color SwatchAdobe Photoshop
.aseAdobe Swatch ExchangeAdobe PhotoshopInDesign, and Illustrator
.txtPaint.NET PalettePaint.NET
.actAdobe Color TableAdobe Photoshop and Illustrator
.pal, .psppalettePaint Shop Pro PalettePaint Shop Pro (Jasc Software / Corel)
.hplHomesite PaletteAllaire Homesite / Macromedia ColdFusion
.csColorSchemerColorSchemer Studio
.palStarCraft PaletteStarCraft
.wpeStarCraft Terrain PaletteStarCraft
.sketchpaletteSketch PaletteSketch
.splSkencil PaletteSkencil (formerly called Sketch)
.socStarOffice ColorsStarOfficeOpenOfficeLibreOffice
.colorsKolourPaint Color CollectionKolourPaint
.colorsPlasma Desktop Color SchemeKDE Plasma Desktop
.themeWindows ThemeWindows Desktop
.themepackWindows ThemeWindows Desktop
.css, .scss, .stylCascading StyleSheetsWeb browsers / web pages
.html, .svg, .jsany text files with CSS colorsWeb browsers / web pages

Did you know?

  • There’s a black and white mode with patterns instead of colors in the palette, which you can get to from Image > Attributes…
  • You can drag the color box and tool box around if you grab them by the right place. You can even drag them out into little windows. You can dock the windows back to the side by double-clicking on their title bars.
  • In addition to the left-click foreground color and the right-click background color, there’s a third color you can access by holding Ctrl while you draw. It starts out with no color so you’ll need to hold Ctrl and select a color first. The fancy thing about this color slot is you can press and release Ctrl to switch colors while drawing.
  • You can apply image transformations like Flip/Rotate, Stretch/Skew or Invert (in the Image menu) either to the whole image or to a selection. Try scribbling with the Free-Form Select tool and then doing Image > Invert
  • These Tips and Tricks from a tutorial for MS Paint also work in JS Paint:
    •  Brush Scaling (+ & - on the number pad to adjust brush size)
    •  “Custom Brushes” (hold Shift and drag the selection to smear it)
    •  The ‘Stamp’ “Tool” (hold Shift and click the selection to stamp it)
    •  Image Scaling (+ & - on the number pad to scale the selection by factors of 2)
    •  Color Replacement (right mouse button with Eraser to selectively replace the foreground color with the background color)
    •  The Grid (Ctrl+G & Zoom to 4x+)
    •  Quick Undo (Pressing a second mouse button cancels the action you were performing. I also made it redoable, in case you do it by accident!)
    •  Scroll Wheel Bug (Hmm, let’s maybe not recreate this?)

Phaser. Unit 8. Dino game clone (part II)

Introduction

In this unit we will build a clone of Google Chrome “secret” Dino game (the original game can be revealed by entering “chrome://dino” in the address bar). We will use simple web technologies such as HTML, CSS, JavaScript and Phaser 3, so that any user can play on any device just using any browser (not only Chrome).

The result is a game which looks almost the same as the original one using less than 10% lines of code. As it can be seen from the Chromium source code, the original game is made of more than 4000 lines of code, whereas the one in this unit has been developed using around 300 lines of code. This means that Phaser could be a much better option than simple JavaScript when thinking about game development.

Some features

This version of the game clones almost every feature of the original one:

  • The speed of the game is increased gradually.
  • Both cactuses and birds will appear randomly to hurt you.
  • Two scores are shown: current and highest.
  • Each time the user reaches 100 points, the score blinks and a sound is played.
  • It can be played on desktop computers using both the mouse and the keyboard:
    • By clicking on the screen to jump or to restart the game.
    • Using the up arrow key or the space bar to jump, the down arrow key to duck under the enemy, and the enter key to restart the game.
  • It can be played on mobile devices by touching the screen, the same way as any single button game.
  • The game is automatically adjusted to fit the screen size, even if it changes after the page is loaded (for example, in case the user rotates the device).
  • Vibration is activated on mobile devices when the game over screen appears.

CSS code (styles.css)

We are going to insert some CSS code to remove any margin and padding, and also to remove the scrollbars, so that the whole window size is used when the game is started:

html, body {
  margin: 0px;
  padding: 0px;
  overflow: hidden;
  height: 100%;
}

HTML code (index.html)

We are going to use a single HTML file to link all the files in the project. This is going to be the “index.html” file:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta name="description" content="Dino game clone using Phaser">
  <meta name="keywords" content="HTML, CSS, JavaScript, Phaser">
  <meta name="author" content="Fernando Ruiz Rico">
  
  <title>Dino Phaser</title>

  <link href="styles.css" rel="stylesheet">
</head>

<body>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>
  <script src="preload.js"></script>
  <script src="create.js"></script>
  <script src="update.js"></script>
  <script src="config.js"></script>
</body>

</html>

Constants and Phaser configuration (config.js)

We are going to define some constants (i.e. screen width and height and text styles) so that we can use them inside every JavaScript file. We will also define a function to set the initial values of some variables before the game is started, together with the usual Phaser configuration, which must be performed as usual.

Moreover we are adding a piece of code to check whether the screen is being resized (i.e. when the device is rotated or the window size is adjusted) so that the whole game is reloaded to fit the new values of the screen width and height.

const WIDTH = window.innerWidth;
const HEIGHT = window.innerHeight;

const TEXT_STYLE = { fill: "#535353", font: '900 35px Courier', resolution: 10 };

function initVariables() {
  this.isGameRunning = false;
  this.gameSpeed = 10;
  this.respawnTime = 0;
  this.score = 0;
}

const config = {
  type: Phaser.AUTO,
  width: WIDTH,
  height: HEIGHT,
  pixelArt: true,
  transparent: true,
  physics: {
    default: 'arcade',
    arcade: {
      debug: false
    }
  },
  scene: { preload: preload, create: create, update: update }
};

let game = new Phaser.Game(config);

let resizing = false;
window.addEventListener('resize', () => {
  if (resizing) clearTimeout(resizing);
  resizing = setTimeout(() => window.location.reload(), 500);
});

The assets

We will need some images, sprites and sounds. You may use any assets you like, but we are providing the original ones so that you can easily develop the same game as the one available in Google Chrome. Some examples are shown below, and also a zip file containing all the assets can be downloaded here.

Images

Cloud
Cactuses
Game over
Restart

Sprites

Dino waiting and running
Dino down
Bird enemy

Sounds

Hit
Jump
Reach

Loading all the assets (preload.js)

So that we can use all those images, sprites and sounds, they have to be loaded previously. That is the purpose of the “preload()” function.

Inside the “initSounds()” and “createAnims()” we will set the volume of the sounds and the frames of the animations respectively.

function preload() {
  this.load.audio('jump', 'assets/jump.m4a');
  this.load.audio('hit', 'assets/hit.m4a');
  this.load.audio('reach', 'assets/reach.m4a');

  this.load.image('ground', 'assets/ground.png');
  this.load.image('dino-idle', 'assets/dino-idle.png');
  this.load.image('dino-hurt', 'assets/dino-hurt.png');
  this.load.image('restart', 'assets/restart.png');
  this.load.image('game-over', 'assets/game-over.png');
  this.load.image('cloud', 'assets/cloud.png');

  this.load.image('obsticle-1', 'assets/cactuses_small_1.png');
  this.load.image('obsticle-2', 'assets/cactuses_small_2.png');
  this.load.image('obsticle-3', 'assets/cactuses_small_3.png');
  this.load.image('obsticle-4', 'assets/cactuses_big_1.png');
  this.load.image('obsticle-5', 'assets/cactuses_big_2.png');
  this.load.image('obsticle-6', 'assets/cactuses_big_3.png');

  this.load.spritesheet('dino', 'assets/dino-run.png', { frameWidth: 88, frameHeight: 94 });
  this.load.spritesheet('dino-down', 'assets/dino-down.png', { frameWidth: 118, frameHeight: 94 });
  this.load.spritesheet('enemy-bird', 'assets/enemy-bird.png', { frameWidth: 92, frameHeight: 77 });
}

function initSounds() {
  this.jumpSound = this.sound.add('jump', { volume: 0.75 });
  this.hitSound = this.sound.add('hit', { volume: 0.75 });
  this.reachSound = this.sound.add('reach', { volume: 0.75 });
}

function createAnims() {
  this.anims.create({
    key: 'dino-waiting',
    frames: this.anims.generateFrameNumbers('dino', { start: 0, end: 1 }),
    frameRate: 1,
    repeat: -1
  })

  this.anims.create({
    key: 'dino-run',
    frames: this.anims.generateFrameNumbers('dino', { start: 2, end: 3 }),
    frameRate: 10,
    repeat: -1
  })

  this.anims.create({
    key: 'dino-down-anim',
    frames: this.anims.generateFrameNumbers('dino-down', { start: 0, end: 1 }),
    frameRate: 10,
    repeat: -1
  })

  this.anims.create({
    key: 'enemy-bird-anim',
    frames: this.anims.generateFrameNumbers('enemy-bird', { start: 0, end: 1 }),
    frameRate: 6,
    repeat: -1
  })
}

Creating the scores, the clouds, the dino, and the game over message (create.js)

  • Scores: Two boxes are used to print both the current and highest scores on the right hand side of the screen.
  • Clouds: A loop will be used to create 3 clouds at once printing the same image on random positions.
  • Dino: The dino will collide with the ground below, and it will jump using the keys defined here (in our case we will use both the up cursor key and the space bar). The mouse functionality is already available by default.
  • Game over: A message will be shown when the user collides with either the birds or the cactuses. After the user clicks on it, this message will be hidden using the “setAlpha(0)” function call.
function initColliders() {
  this.physics.add.collider(this.dino, this.obsticles, () => {
    initVariables.call(this);
    updateHighScore.call(this);
    this.hitSound.play();
    this.physics.pause();
    this.anims.pauseAll();
    this.dino.setTexture('dino-hurt');
    this.gameOverScreen.setAlpha(1);
    if (window.navigator.vibrate) window.navigator.vibrate(50);
  }, null, this);
}

function createScore() {
  this.scoreText = this.add.text(WIDTH - 25, HEIGHT / 3, "", TEXT_STYLE).setOrigin(1, 1);
  this.highScoreText = this.add.text(0, HEIGHT / 3, "", TEXT_STYLE).setOrigin(1, 1).setAlpha(0.75);
}

function createClouds() {
  this.clouds = this.add.group();
  for (let i = 0; i < 3; i++) {
    const x = Phaser.Math.Between(0, WIDTH);
    const y = Phaser.Math.Between(HEIGHT / 3, HEIGHT / 2);
    this.clouds.add(this.add.image(x, y, 'cloud'));
  }
  this.clouds.setAlpha(0);
}

function createDino() {
  this.dino = this.physics.add.sprite(0, HEIGHT / 1.5, 'dino-idle').setSize(50).setGravityY(5000).setOrigin(0, 1);
  this.dino.play('dino-waiting', true);
  this.physics.add.collider(this.dino, this.belowGround);
  this.cursors = this.input.keyboard.createCursorKeys();
  this.spaceBar = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);

  this.startText = this.add.text(WIDTH - 15, HEIGHT / 1.5, "Press to play", TEXT_STYLE).setOrigin(1);
}

function createGameOverScreen() {
  this.gameOverText = this.add.image(0, 0, 'game-over');
  this.restart = this.add.image(0, 50, 'restart').setInteractive();
  this.gameOverScreen = this.add.container(WIDTH / 2, HEIGHT / 2 - 50).setAlpha(0);
  this.gameOverScreen.add([this.gameOverText, this.restart]);
  this.enter = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER);

  this.restart.on('pointerdown', () => restartGame.call(this));
}

function createWorld() {
  this.ground = this.add.tileSprite(0, HEIGHT / 1.5, 88, 26, 'ground').setOrigin(0, 1);
  this.belowGround = this.add.rectangle(0, HEIGHT / 1.5, WIDTH, HEIGHT / 3).setOrigin(0, 0);
  this.physics.add.existing(this.belowGround);
  this.belowGround.body.setImmovable();
  this.obsticles = this.physics.add.group();
}

function create() {
  initVariables.call(this);

  initSounds.call(this);
  createAnims.call(this);
  createWorld.call(this);
  createClouds.call(this);
  createScore.call(this);
  createDino.call(this);
  initColliders.call(this);
  createGameOverScreen.call(this);

  setInterval(() => updateScore.call(this), 100);
}

Controlling the player and displaying the birds and the cactuses (update.js)

  • Starting the game and building the ground: The “startGame()” method will build the ground progresively, just once, when the game is started the very first time.
  • Restarting the game: After the game over message appears, the user may click on the screen to start a new game. This is done inside the “restartGame()” method, which hides the message using the “setAlpha(0)” method call, restarts the score and resumes all the physics.
  • Updating the scores: The function “updateScore()” will not only update the current score, but it will also increment the game speed, and it will play a sound each time the user reaches 100 points, making the numbers blink at the same time. The “updateHighScore()” function will keep a record of the highest score each time a game is over.
  • Placing and moving the birds and the cactuses: The “placeObsticle()” function will create either birds or cactuses randomly, and the “update()” function will move everything to the left except the dino, which will always remain in the same position.
function startGame() {
  this.startText.destroy();
  this.dino.setVelocityX(80);
  this.dino.play('dino-run', 1);

  function buildGround() {
    this.ground.width += (WIDTH / 100);
    if (this.ground.width > WIDTH) {
      clearInterval(interval);
      this.isGameRunning = true;
      this.ground.width = WIDTH;
      this.dino.setVelocityX(0);
      this.scoreText.setAlpha(1);
      this.clouds.setAlpha(1);
    }
  }
  const interval = setInterval(() => buildGround.call(this), 15);
}

function restartGame() {
  this.dino.setVelocityY(0);
  this.dino.body.height = 92;
  this.dino.body.offset.y = 0;
  this.physics.resume();
  this.anims.resumeAll();
  this.obsticles.clear(true, true);
  this.gameOverScreen.setAlpha(0);
  this.isGameRunning = true;
}

function updateScore() {
  if (!this.isGameRunning) return;

  this.score++;
  this.gameSpeed += 0.01;

  if (this.score % 100 == 0) {
    this.reachSound.play();

    this.tweens.add({
      targets: this.scoreText,
      duration: 100,
      repeat: 3,
      alpha: 0,
      yoyo: true
    })
  }

  this.scoreText.setText(("0000" + this.score).slice(-5));
}

function updateHighScore() {
  this.highScoreText.x = this.scoreText.x - this.scoreText.width - 30;

  const highScore = this.highScoreText.text.substr(this.highScoreText.text.length - 5);
  const newScore = Number(this.scoreText.text) > Number(highScore) ? this.scoreText.text : highScore;

  this.highScoreText.setText(`HI ${newScore}`);
}

function placeObsticle() {
  let obsticle;
  const obsticleNum = Phaser.Math.Between(1, 7);

  if (obsticleNum > 6) {
    obsticle = this.obsticles.create(WIDTH, HEIGHT / 1.5 - Phaser.Math.Between(20, 50), 'enemy-bird');
    obsticle.body.height /= 1.5
    obsticle.play('enemy-bird-anim', 1);
  } else {
    obsticle = this.obsticles.create(WIDTH, HEIGHT / 1.5, `obsticle-${obsticleNum}`)
  }

  obsticle.setOrigin(0, 1).setImmovable();
}

function update(time, delta) {
  if (this.isGameRunning) {
    this.ground.tilePositionX += this.gameSpeed;

    this.respawnTime += delta * this.gameSpeed * 0.08;
    if (this.respawnTime >= 1500) {
      placeObsticle.call(this);
      this.respawnTime = 0;
    }

    this.obsticles.getChildren().forEach(obsticle => {
      obsticle.x -= this.gameSpeed;
      if (obsticle.getBounds().right < 0) {
        this.obsticles.killAndHide(obsticle);
      }
    })

    this.clouds.getChildren().forEach(cloud => {
      cloud.x -= 0.5;
      if (cloud.getBounds().right < 0) {
        cloud.x = WIDTH;
      }
    })
  }
  else if (this.gameOverScreen.alpha && this.enter.isDown) {
    restartGame.call(this);
  }

  if (this.dino.body.onFloor() && !this.dino.body.velocity.x && !this.gameOverScreen.alpha &&
    (this.cursors.up.isDown || this.spaceBar.isDown || this.input.activePointer.isDown)) {
    this.jumpSound.play();
    this.dino.anims.stop();
    this.dino.setVelocityY(-1600);
    this.dino.setTexture('dino', 0);

    if (!this.isGameRunning) startGame.call(this);
  }
  else if (this.isGameRunning) {
    if (this.cursors.down.isDown) {
      this.dino.play('dino-down-anim', true);
      this.dino.body.height = 58;
      this.dino.body.offset.y = 34;
    }
    else {
      this.dino.play('dino-run', true);
      this.dino.body.height = 92;
      this.dino.body.offset.y = 0;
    }
  }
}

Proposed exercise: Using your own assets

Create a new version of the Dino game using your own assets (images, sprites and sounds). You can also change any other things you like.

Do not forget to create all the files required to run the code in this unit:

  • styles.css
  • index.html
  • preload.js
  • create.js
  • update.js
  • config.js
  • assets folder

You may find many free assets in OpenGameArt, and some new versions of the Dino game in github:

Enjoy the game!

You can enjoy playing this wonderful game online here.

Phaser 3 game examples written in TypeScript

Play the games

Alpha adjust

Alpha adjust

Asteroid

Asteroid

Blockade

Blockade

Blocks

Blocks

Breakout

Breakout

Candy Crush

Candy Crush

Clocks

Clocks

Coin runner

Coin runner

Endless runner

Endless runner

Flappy Bird

Flappy Bird

Snake

Snake

Space Invaders

Space Invaders

Super Mario Land

Super Mario Land

Tank

Tank

Run the games in your computer

Since all the games are open source, you can run all the games in your own computer. Just follow these steps:

  1. Install Nodejs (in case you don’t have it yet).
  2. Download the zip file containing the source code of the game you like.
  3. Open a terminal inside the project folder.
  4. Execute “npm install”.
  5. Execute “npm run dev”.
  6. Open the address “http://localhost:8080/” on your browser.

You can download the source code from the following links:

More information

You will find more information on github.

Phaser. Unit 9. Drawing maps from plain text

Introduction

In this unit we will define a simple Phaser game that can be used as a template to define an unlimited number of levels, enemies and objects. The map will be drawn from a simple array of tiles where several characters will be used to choose the positions of each item.

We will also use npm and webpack to provide a friendly development environment, so that the application will be automatically compiled and restarted each time a file is changed. A web server is also provided by webpack to test the application, and a production version of the code can be also obtained to upload it to any public server.

Some features

These are some of the features to be implemented:

  • Show several types of animated objects that can be collected individually.
  • Keep several counters with the number of the items collected.
  • Show several types of animated enemies that can be killed just by jumping over them.
  • Move automatically the enemies from left to right and viceversa using spritesheets that contain only a single direction.
  • Draw the map using plain characters that are matched againt tiles, objects, enemies, player, and goal.
  • Easily define an unlimited number of levels and let the player jump from one level to the other just by reaching a goal in the map.
  • Display a minimap automatically generated from the real sized map.
  • Play a different background music for each level, and a different sound when either objects are collected or enemies are killed.
  • Allow the user to play the game on mobile phones and tablets with touch controls to jump and also to activate both left and right movements.
  • Implement PWA features to let the users install the application in mobile devices and also to lock the screen in landscape mode.
  • Adjust the game automatically to the size of the window where the game is displayed, and also after the window is resized on desktop computers.

Loading the assets (src/scripts/scenes/preloadScene.ts)

Inside this file we will load all the assets (both audio and images) so that they are ready to be played or displayed after the game is started:

import Levels from "../components/levels/levels"

export default class PreloadScene extends Phaser.Scene {
  constructor() {
    super({ key: 'PreloadScene' })
  }

  preload() {
    const images = ['tile-left', 'tile-middle', 'tile-right', 'tile-single', 'controls', 'background', 'goal']
    images.forEach(img => {
      this.load.image(img, `assets/img/${img}.png`)
    })

    this.load.spritesheet('player', 'assets/img/player.png', { frameHeight: 165, frameWidth: 120 })
    this.load.spritesheet('coin', 'assets/img/coin.png', { frameHeight: 42, frameWidth: 42 })
    this.load.spritesheet('key', 'assets/img/key.png', { frameHeight: 176, frameWidth: 176 })
    this.load.spritesheet('bee', 'assets/img/bee.png', { frameHeight: 100, frameWidth: 128 })
    this.load.spritesheet('slime', 'assets/img/slime.png', { frameHeight: 68, frameWidth: 112 })
    // @ts-ignore
    this.load.spine('boy', 'assets/spine/boy.json', 'assets/spine/boy.atlas')

    const audios = ['bee', 'slime', 'coin', 'key', 'door', 'hurt', 'music']
    audios.forEach(audio => {
      this.load.audio(audio, `assets/audio/${audio}.mp3`)
    })

    for(let i=0; i<Levels.length; i++) {
      this.load.audio(`level${i}`, `assets/audio/level${i}.mp3`)
    }
  }

  create() {
    this.scene.start('MainScene')
  }
}

Drawing the map and placing everything on it

We will use plain characters to represent the player, the goal, the objects, the enemies and the ground:

  • P: Player
  • G: Goal (the door to jump to the next level)
  • Objects:
    • C: Coin
    • K: Key (at least 1 key is required to open the door to the next level)
  • Enemies:
    • B: Bee (a flying enemy)
    • S: Slime (an enemy crawling on the ground)
  • [//////]: The ground

Defining the levels (src/scripts/components/levels/levels.ts)

As it can be seen in the “levels.ts” file, the map of each level can be easily defined using simple characters. We can also place all the enemies, objects, player and goal at any position in the map:

type Level = string[]

// prettier-ignore
const level0: Level = [
  '   K    S       ',
  '  [/////]       ',
  '                ',
  '   P         G  ',
  '[/////]   [////]',
]

// prettier-ignore
const level1: Level = [
  '               B  K                   ',
  '               [/////]                ',
  '              C     C                 ',
  '   S        B            S    S    G  ',
  '[//]       [////]   [////]   [//////] ',
  '    C   C                             ',
  ' P        B                           ',
  '[///]  [//]                           ',
]

// prettier-ignore
const level2: Level = [
  ' P                     [//]       G   ',
  '[/]    C   B        C           [/]   ',
  '         [//]     [//]                ',
  '      S                     B  B      ',
  ' C  [//]      B            [///]      ',
  '            [///]     []  C        C  ',
  '[]        K                           ',
  '         [/]            [/]      [/]  '
]

// prettier-ignore
const level3: Level = [
  '       C                                    ',
  '                      C                 C   ',
  '      [///]                 C      [////]   ',
  '           [///]       S  [//]  K           ',
  '                   [///]      [///]         ',
  '             S                              ',
  '         [///]           C              G   ',
  '      C            C               [////]   ',
  '  P                     S  B                ',
  '[//////]       [///]  [////]                '
]

// prettier-ignore
const level4: Level = [
  '                        C  C         S                                  ',
  '                                 [//////]      K     B               G  ',
  '                  C    [/////]                [///////]          [////] ',
  '                                                                        ',
  '  P      S     B    B              C       S     S         S            ',
  '[////////]  [///////]             [/]    [////////////////////]         '
]

const Levels: Level[] = [level0, level1, level2, level3, level4]
export default Levels

Matching characters to textures (src/scripts/components/map/map.ts)

In case we want to add new enemies or objects, we will have to match a new character against the new texture:

import Levels from '../levels/levels'
export class Map {
  info: TilesConfig[]
  size: MapSize

  public static calcCurrentLevel(currentLevel: number) {
    const MAX_LEVELS = Levels.length
    return currentLevel % MAX_LEVELS
  }

  constructor(currentLevel: number) {
    const TILE_SIZE = 96
    const config: any = {
      '[': {
        type: 'tile',
        texture: 'tile-left'
      },
      '/': {
        type: 'tile',
        texture: 'tile-middle'
      },
      ']': {
        type: 'tile',
        texture: 'tile-right'
      },
      'G': {
        type: 'goal',
        texture: 'goal'
      },
      'C': {
        type: 'object',
        texture: 'coin'
      },
      'K': {
        type: 'object',
        texture: 'key'
      },      
      'S': {
        type: 'enemy',
        texture: 'slime'
      },
      'B': {
        type: 'enemy',
        texture: 'bee'
      },
      'P': {
        type: 'player',
        texture: 'player'
      }
    }

    const map = Levels[Map.calcCurrentLevel(currentLevel)]

    // the player can jump a bit higher than the map's height
    const paddingTop = 4 * TILE_SIZE

    this.size = {
      x: 0,
      y: 0,
      width: map[0].length * TILE_SIZE,
      height: map.length * TILE_SIZE + paddingTop
    }
    this.info = []

    map.forEach((row, y) => {
      for (let i = 0; i < row.length; i++) {
        const tile = row.charAt(i)
        const x = i
        if (tile !== ' ') {
          let info = { ...config[tile.toString()], x: x * TILE_SIZE, y: y * TILE_SIZE + paddingTop }
          this.info.push(info)
        }
      }
    })
  }
}

Enemies (src/scripts/components/enemies/enemiesGroup.ts)

All the tiles in the map are read and the enemies are created depending on the texture field:

import BeeSprite from './bee'
import SlimeSprite from './slime'

export default class EnemiesGroup extends Phaser.GameObjects.Group {
  tiles: TilesConfig[]
  TILE_SIZE = 96
  constructor(scene: Phaser.Scene, tilesConfig: TilesConfig[]) {
    super(scene)

    this.tiles = tilesConfig.filter(tile => tile.type === 'tile')
    let enemyTypes = tilesConfig.filter(tile => tile.type === 'enemy')

    let enemies: Array<BeeSprite | SlimeSprite> = []
    enemyTypes.forEach(enemy => {
      switch (enemy.texture) {
        case 'bee':
          enemies.push(new BeeSprite(scene, enemy.x, enemy.y))
          break
        case 'slime':
          enemies.push(new SlimeSprite(scene, enemy.x, enemy.y))
          break
      }
    })
    this.addMultiple(enemies)
  }

  update() {
    // check if the enemy should change its direction
    // @ts-ignore
    this.children.iterate((enemy: BeeSprite | SlimeSprite) => {
      if (enemy.dead) return

      let enemyIsMovingRight = enemy.body.velocity.x >= 0

      let hasGroundDetection = this.tiles.filter(tile => {
        let enemyPositionX = enemyIsMovingRight ? enemy.body.right : enemy.body.left
        let x = enemyPositionX + 32 > tile.x && enemyPositionX - 32 < tile.x + this.TILE_SIZE
        let y =
          enemy.body.bottom + this.TILE_SIZE / 2 > tile.y &&
          enemy.body.bottom + this.TILE_SIZE / 2 < tile.y + this.TILE_SIZE
        return x && y
      })

      if (hasGroundDetection.length === 0) {
        //@ts-ignore
        enemy.body.setVelocityX(enemy.body.velocity.x * -1)
        enemy.setFlipX(!enemyIsMovingRight)
      }
    }, null)
  }
}

The bee (src/scripts/components/enemies/bee.ts)

Frames 0 and 1 represent the usual movement, while frame number 2 is used when the bee is dead:

Bee enemy (assets/img/bee.png)
Dead bee (assets/audio/bee.mp3)
import EnemyClass from './enemyClass'

export default class BeeSprite extends EnemyClass {

  constructor(scene: Phaser.Scene, x: number, y: number) {
    super(scene, x, y, 'bee')
    scene.add.existing(this)
    scene.physics.add.existing(this)

    scene.anims.create({
      key: 'fly',
      frames: scene.anims.generateFrameNumbers('bee', { start: 0, end: 1 }),
      frameRate: 8,
      repeat: -1
    })
    this.play('fly')

    //@ts-ignore
    this.body.setVelocityX(-120)
    this.setOrigin(0.5, 1)
    this.body.setSize(80, 135)
    this.body.setOffset((this.width - 80) / 2, 30)

    this.audio = scene.sound.add('bee', { volume: 1.0 })
  }

  update() { }

  kill() {
    if (this.dead) return

    this.body.setSize(80, 40)

    this.removeEnemy(2) // Frame number 2 is used when the bee is dead
  }
}

The slime (src/scripts/components/enemies/slime.ts)

Frames from 0 to 4 represent the usual movement, while frame number 5 is used when the slime is dead:

Slime enemy (assets/img/slime.png)
Dead slime (assets/audio/slime.mp3)
import EnemyClass from './enemyClass'

export default class SlimeSprite extends EnemyClass {

  constructor(scene: Phaser.Scene, x: number, y: number) {
    super(scene, x, y, 'slime')
    scene.add.existing(this)
    scene.physics.add.existing(this)

    scene.anims.create({
      key: 'crawl',
      frames: scene.anims.generateFrameNumbers('slime', { start: 0, end: 4 }),
      frameRate: 6,
      yoyo: true,
      repeat: -1
    })
    this.play('crawl')

    //@ts-ignore
    this.body.setVelocityX(-60)
    this.setOrigin(0.5, 1)
    this.setScale(1)
    this.body.setSize(this.width - 40, this.height - 20)
    this.body.setOffset(20, 20)

    this.audio = scene.sound.add('slime', { volume: 1.0 })
  }

  update() { }

  kill() {
    if (this.dead) return

    this.removeEnemy(5) // Frame number 5 is used when the bee is dead
  }
}

Killing the enemies (src/scripts/scenes/mainScene.ts)

The player kills an enemy when it jumps over it. As a reward, it will get 5 points:

... 
   this.physics.add.overlap(this.player, this.enemiesGroup, (player: Player, enemy: EnemySprite) => {
      if (enemy.dead) return
      if (enemy.body.touching.up && player.body.touching.down) {
        this.score += 5
        scoreText.update(this.score, this.keys)
        player.killEnemy()
        enemy.kill()
      } else {
        player.kill()
      }
    })
...

Objects (src/scripts/components/objects/objectsGroup.ts)

All the tiles in the map are read and the objects are created depending on the texture field:

import CoinSprite from './coin'
import KeySprite from './key'

export default class ObjectsGroup extends Phaser.GameObjects.Group {
  constructor(scene: Phaser.Scene, tilesConfig: TilesConfig[]) {
    super(scene)

    let objectTypes = tilesConfig.filter(tile => tile.type === 'object')

    let objects: Array<CoinSprite | KeySprite> = []
    objectTypes.forEach(object => {
      switch (object.texture) {
        case 'coin':
          objects.push(new CoinSprite(scene, object.x, object.y))
          break
        case 'key':
          objects.push(new KeySprite(scene, object.x, object.y))
          break
      }
    })

    this.addMultiple(objects)
  }
}

The coin (src/scripts/components/objects/coin.ts)

All the frames are selected to create the animation:

The coin (assets/img/coin.png)
Collecting coin (assets/audio/coin.mp3)
import ObjectClass from "../objects/objectClass";

export default class CoinSprite extends ObjectClass {

  constructor(scene: Phaser.Scene, x: number, y: number) {
    super(scene, x, y, 'coin')
    scene.add.existing(this)
    scene.physics.add.existing(this)

    this.setImmovable()
    this.setScale(1.5)
    // @ts-ignore
    this.body.setAllowGravity(false)

    scene.anims.create({
      key: 'spincoin',
      frames: scene.anims.generateFrameNames('coin'),
      frameRate: 16,
      repeat: -1
    })
    this.play('spincoin')

    this.audio = scene.sound.add('coin', { volume: 1.0 })
  }
}

The key (src/scripts/components/objects/key.ts)

All the frames are selected to create the animation:

The key (assets/img/key.png)
Collecting key (assets/audio/key.mp3)
import ObjectClass from "./objectClass";

export default class CoinSprite extends ObjectClass {

  constructor(scene: Phaser.Scene, x: number, y: number) {
    super(scene, x, y, 'key')
    scene.add.existing(this)
    scene.physics.add.existing(this)

    this.setImmovable()
    this.setScale(0.7)
    // @ts-ignore
    this.body.setAllowGravity(false)

    scene.anims.create({
      key: 'spinkey',
      frames: scene.anims.generateFrameNames('key'),
      frameRate: 10,
      repeat: -1
    })
    this.play('spinkey')

    this.audio = scene.sound.add('key', { volume: 1.0 })
  }
}

Collecting objects (src/scripts/scenes/mainScene.ts)

The counters of coins and keys are incremented when any of these objects is collected:

...
    this.physics.add.overlap(this.player, objectsGroup, (player: Player, object: ObjectClass) => {
      if (object.collecting) return
      if (object instanceof CoinSprite) this.score++
      else if (object instanceof KeySprite) this.keys++
      scoreText.update(this.score, this.keys)
      object.collect()
    })
...

The player (src/scripts/components/player/player.ts)

The get a more realistic movement, the player is animated using the “spine” plugin. The full documentation about this plugin can be found here, and some examples can be found here.

Player animation using spine plugin (assets/spine/boy.png)
Player killed (assets/audio/hurt.mp3)
import Controls from '../controls/controls'
import PlayerSpine from './playerSpine'

export default class Player extends Phaser.Physics.Arcade.Sprite {
  private _dead: boolean = false
  private _halt: boolean = false
  private mapSize: MapSize
  playerSpine: PlayerSpine
  audioHurt: any

  constructor(scene: Phaser.Scene, player: TilesConfig, mapSize: MapSize, level: number) {
    super(scene, player.x, player.y, player.texture)
    scene.add.existing(this)
    scene.physics.add.existing(this)

    this.scene = scene
    this.mapSize = mapSize

    this.setVisible(false)

    this.setOrigin(0, 1)
    this.setDragX(1500)
    this.body.setSize(70, 132)
    this.body.setOffset(25, 24)

    let theSkin = level % 2 == 0 ? 'blue' : 'green'
    this.playerSpine = new PlayerSpine(scene, this.body.center.x, this.body.bottom)
    this.playerSpine.setSkin(theSkin)

    this.audioHurt = scene.sound.add('hurt', { volume: 1.0 })
  }

  kill() {
    this._dead = true

    this.audioHurt.play()

    // animate the camera if the player dies
    this.scene.cameras.main.shake(500, 0.025)
    this.scene.time.addEvent({
      delay: 500,
      callback: () => this.scene.scene.restart()
    })
  }

  killEnemy() {
    this.playerSpine.spine.customParams.isKilling = true
    this.setVelocityY(-600)
  }

  halt() {
    this.body.enable = false
    this._halt = true
  }

  update(cursors: any, controls: Controls) {
    if (this._halt || this._dead) return

    // check if out of camera and kill
    if (this.body.right < this.mapSize.x || this.body.left > this.mapSize.width || this.body.top > this.mapSize.height)
      this.kill()

    // controls left & right
    if (cursors.left.isDown || controls.leftIsDown) {
      this.setVelocityX(-500)
      this.playerSpine.spine.setScale(-1, 1)
    } else if (cursors.right.isDown || controls.rightIsDown) {
      this.setVelocityX(550)
      this.playerSpine.spine.setScale(1, 1)
    }
    // controls up
    if ((cursors.up.isDown || cursors.space.isDown || controls.upIsDown) && this.body.blocked.down) {
      this.setVelocityY(-1250)
    }

    // update spine animation
    this.playerSpine.update(this)
  }
}

The goal (src/scripts/components/goal/goalSprite.ts)

When the user collides with the door, an achievement sound will be played, and it will jump to the next level.

The goal (assets/img/goal.png)
Next level (assets/audio/door.mp3)
export default class GoalSprite extends Phaser.Physics.Arcade.Sprite {
  private _loadNextLevel: boolean = false;
  private audio: any;

  constructor(scene: Phaser.Scene, tilesConfig: TilesConfig) {
    super(scene, tilesConfig.x, tilesConfig.y + 14, 'goal')
    scene.add.existing(this)
    scene.physics.add.existing(this)

    this.setImmovable(true)
    // @ts-ignore
    this.body.setAllowGravity(false)
    this.setOrigin(0, 0.5)

    this.audio = scene.sound.add('door', { volume: 1.0 })
  }

  get loadNextLevel() {
    return this._loadNextLevel
  }

  nextLevel(scene: Phaser.Scene, level: number, score: number, keys: number) {
    if (this._loadNextLevel) return
    this._loadNextLevel = true

    this.audio.play()

    scene.cameras.main.fadeOut()
    scene.time.addEvent({
      delay: 2000,
      callback: () => {
        scene.scene.restart({ level: level += 1, score: score, keys: keys })
      }
    })
  }
}

Jumping to the next level (src/scripts/scenes/mainScene.ts)

After colliding with the door, the user will only jump to the next level in case it collected previously at least one key:

...
    this.physics.add.overlap(this.player, this.goal, (player: Player, goal: GoalSprite) => {
      if (!this.keys) return
      player.halt()
      this.keys--
      goal.nextLevel(this, this.level, this.score, this.keys)
    })
...

Progressive Web App (PWA)

This game is 100% PWA ready, so that it can be installed and executed in your mobile device as it was an app from the Play Store. You can easily personalize its settings by following these steps:

  • Replace both icons in /pwa/icons with your own.
    • One is 512×512 the other 192×192.
  • Add your own favicon.ico to /src.
  • Adjust these parameters in the manifest.json file in /pwa:
    • short_name: Max. 12 characters.
    • name: The full game name.
    • orientation: “landscape” or “portrait”.
    • background_color: color of the splash screen.
    • theme_color: color of the navbar – has to match the theme-color in the index.html file.
  • You can leave the sw.js (serviceWorker) file in /pwa as it is.
  • Change the gameName in /webpack/webpack.common.js

Read more about PWA on developers.google.com.

Running the game

You can get the game up and running just following these steps:

  1. Install nodejs (in case you do not have installed it yet). Select the version recommended for most users and just click next on each option to use the default settings.
  2. Download the code. You can download from this link a single zip file containing the full game template and all the assets.
  3. Uncompress the zip file, and open a terminal from inside the project folder.
  4. Execute “npm install” to install all the dependencies requested by the project.
  5. Execute “npm run start” to compile and execute the code. After this command is completed, a new tab will be opened in your default browser. The game will also be refreshed automatically when the source code changes.
  6. Execute “npm run build” to get a production version. After executing this command, all the html, css and js files, and also all the assets required to run the game will be put inside the “dist” folder, ready to be uploaded to any public web server. For example, you could make your game available to anyone just by creating a new project in https://replit.com and uploading all the files inside “dist” to the root folder of the replit project.

Play the game on your browser

You have also a version ready to be played online here.

Install the game in your phone

Since all the PWA functionality is included in this project, you can install your application in any mobile device (Chrome browser and Android operating system are the preferred options for this purpose). You will get the same behaviour as the apps in the play store: an icon will be created after carrying out the installation from your browser, a loading screen will be shown when the game is started, and the game will run in fullscreen and landscape mode.

When you open the address of the game in your browser, a message to install the application is displayed after a few seconds:

Installing the app

Or you can also install the game manually just by selecting the option “Instalar aplicación” from the browser menu:

Installing the app

Bibliography

More information about the template used to build this game can be found on github.

Animated sprite editors and pixel art tools

Introduction

You will find in this post several free and open-source 2D sprite editors and pixel art tools that can be used on your browser to create fancy pictures and animations.

Pixelorama

Pixelorama is an awesome free and open source pixel art editor, proudly created with the Godot engine, by Orama Interactive. Whether you want to make animated pixel art, game graphics, tiles and any kind of pixel art you want, Pixelorama has you covered with its variety of tools and features (you can find online documentation here). Free to use for everyone:

Pixelorama

Current features

  • A variety of different tools to help you draw, with the ability to map a different tool in each left and right mouse buttons.
  • Are you an animator? Pixelorama has its own animation timeline just for you! You can work at an individual cel level, where each cel refers to a unique layer and frame. Supports onion skinning, cel linking, motion drawing and frame grouping with tags.
  • Custom brushes, including random brushes.
  • Create or import custom palettes.
  • Import images and edit them inside Pixelorama. If you import multiple files, they will be added as individual animation frames. Importing spritesheets is also supported.
  • Export your gorgeous art as PNG, as a single file, a spritesheet or multiple files, or GIF file.
  • Pixel perfect mode for perfect lines, for the pencil, eraser & lighten/darken tools.
  • Autosave support, with data recovery in case of a software crash.
  • Horizontal & vertical mirrored drawing.
  • Tile mode for pattern creation.
  • Rectangular & isometric grid types.
  • Scale, rotate and apply multiple image effects to your drawings.
  • Multi-language localization support! See the Crowdin page for more details.

You can find more information on github.

Piskel

Piskel is an easy-to-use sprite editor. It can be used to create game sprites, animations, pixel-art, etc.:

Piskel

The Piskel editor is purely built in JavaScript, HTML and CSS, using also the following libraries :

  • spectrum : awesome standalone colorpicker.
  • gifjs : generate animated GIFs in javascript, using webworkers.
  • supergif : modified version of SuperGif to parse and import GIFs.
  • jszip : create, read and edit .zip files with Javascript.
  • canvas-toBlob : shim for canvas toBlob.
  • jquery : used sporadically in the application.
  • bootstrap-tooltip : nice tooltips.

You can find more information on github.

Pixel Art to CSS

Did you know that you can create pixel art using CSS? Pixel Art to CSS is an online editor that helps you with that task. Combining the power of both box-shadow and keyframes CSS properties, you will get CSS code ready to use in your site. Furthermore, you can download your work in different formats such as a static image, animated GIF or sprite like image.

Pixel Art to CSS aims to be an intuitive tool by its simplicity, however it is equipped with a wide range of features: customize your color palette, go back and forth in time, modify animation settings, save or load your projects, among others.

Pixel Art to CSS

This application has been built with the following technologies:

  • React: Library to build the UI.
  • Redux: Implements a Flux like architecture.
  • ImmutableJS: Helps to keep the data immutable aiming to avoid side effects.
  • PostCSS: Handle the app CSS.
  • NodeJS + Express: Optional server side to build an universal application, create and serve the generated drawings.

You can find more information on gihub.

Pixel art

A bit old, and not fully functional, but still, another interesting browser based pixel art maker and animation tool:

Pixel art

You will find more information on github.

Phaser. Unit 7. Dino game clone (part I)

Introduction

In this unit we will build a clone of Google Chrome “secret” Dino game (the original game can be revealed by entering “chrome://dino” in the address bar). We will use simple web technologies such as HTML, CSS, JavaScript and Phaser 3, so that any user can play on any device just using any browser (not only Chrome).

The result is a game which looks almost the same as the original one using less than 10% lines of code. As it can be seen from the Chromium source code, the original game is made of more than 4000 lines of code, whereas the one in this unit has been developed using around 300 lines of code. This means that Phaser could be a much better option than simple JavaScript when thinking about game development.

Some features

This version of the game clones almost every feature of the original one:

  • The speed of the game is increased gradually.
  • Both cactuses and birds will appear randomly to hurt you.
  • Two scores are shown: current and highest.
  • Each time the user reaches 100 points, the score blinks and a sound is played.
  • It can be played on desktop computers using both the mouse and the keyboard:
    • By clicking on the screen to jump or to restart the game.
    • Using the up arrow key or the space bar to jump, the down arrow key to duck under the enemy, and the enter key to restart the game.
  • It can be played on mobile devices by touching the screen, the same way as any single button game.
  • The game is automatically adjusted to fit the screen size, even if it changes after the page is loaded (for example, in case the user rotates the device).
  • Vibration is activated on mobile devices when the game over screen appears.

CSS code (styles.css)

We are going to insert some CSS code to remove any margin and padding, and also to remove the scrollbars, so that the whole window size is used when the game is started:

html, body {
  margin: 0px;
  padding: 0px;
  overflow: hidden;
}

HTML code (index.html)

We are going to use a single HTML file to link all the files in the project. This is going to be the “index.html” file:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta name="description" content="Dino game clone using Phaser">
  <meta name="keywords" content="HTML, CSS, JavaScript, Phaser">
  <meta name="author" content="Fernando Ruiz Rico">
  
  <title>Dino Phaser</title>

  <link href="styles.css" rel="stylesheet">
</head>

<body>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>
  <script src="preload.js"></script>
  <script src="create.js"></script>
  <script src="update.js"></script>
  <script src="config.js"></script>
</body>

</html>

Constants and Phaser configuration (config.js)

We are going to define some constants (i.e. screen width and height and text styles) so that we can use them inside every JavaScript file. We will also define a function to set the initial values of some variables before the game is started, together with the usual Phaser configuration, which must be performed as usual.

Moreover we are adding a piece of code to check whether the screen is being resized (i.e. when the device is rotated or the window size is adjusted) so that the whole game is reloaded to fit the new values of the screen width and height.

// Fill the whole width and height of the screen
const WIDTH = window.innerWidth;
const HEIGHT = window.innerHeight;

// Time in milliseconds for each obsticle (lower = faster)
const OBSTICLE_TIME = 750;

// Text style to be used when printing texts (scores, etc.)
const TEXT_STYLE = { fill: "#535353", font: '900 35px Courier', resolution: 10 };

// Variables to be initialized each time the game is restarted
function initVariables() {
  this.isGameRunning = false;
  this.gameSpeed = 10;
  this.respawnTime = 0;
  this.score = 0;
}

// Phaser initialization
let game = new Phaser.Game({
  type: Phaser.AUTO,
  width: WIDTH,
  height: HEIGHT,
  pixelArt: true,
  transparent: true,
  physics: {
    default: 'arcade',
    arcade: {
      debug: false
    }
  },
  scene: { preload: preload, create: create, update: update }
});

// Reload the game when the device orientation changes or the user resizes the window
let resizing = false;
window.addEventListener('resize', () => {
  if (resizing) clearTimeout(resizing);
  resizing = setTimeout(() => window.location.reload(), 500);
});

The assets

We will need some images, sprites and sounds. You may use any assets you like, but we are providing the original ones so that you can easily develop the same game as the one available in Google Chrome. Some examples are shown below, and also a zip file containing all the assets can be downloaded here.

Images

Cloud
Cactuses
Game over
Restart

Sprites

Dino waiting and running
Bird enemy

Sounds

Hit
Jump
Reach

Loading all the assets (preload.js)

So that we can use all those images, sprites and sounds, they have to be loaded previously. That is the purpose of the “preload()” function.

Inside the “initSounds()” and “createAnims()” we will set the volume of the sounds and the frames of the animations respectively.

// Load all the audios, images and sprites
function preload() {
  this.load.audio('jump', 'assets/jump.m4a');
  this.load.audio('hit', 'assets/hit.m4a');
  this.load.audio('reach', 'assets/reach.m4a');

  this.load.image('ground', 'assets/ground.png');
  this.load.image('dino-idle', 'assets/dino-idle.png');
  this.load.image('dino-hurt', 'assets/dino-hurt.png');
  this.load.image('restart', 'assets/restart.png');
  this.load.image('game-over', 'assets/game-over.png');
  this.load.image('cloud', 'assets/cloud.png');

  this.load.image('obsticle-1', 'assets/cactuses_small_1.png');
  this.load.image('obsticle-2', 'assets/cactuses_small_2.png');
  this.load.image('obsticle-3', 'assets/cactuses_small_3.png');
  this.load.image('obsticle-4', 'assets/cactuses_big_1.png');
  this.load.image('obsticle-5', 'assets/cactuses_big_2.png');
  this.load.image('obsticle-6', 'assets/cactuses_big_3.png');

  this.load.spritesheet('dino', 'assets/dino-run.png', { frameWidth: 88, frameHeight: 94 });
  this.load.spritesheet('enemy-bird', 'assets/enemy-bird.png', { frameWidth: 92, frameHeight: 77 });
}

// Set the volume of each audio
function initSounds() {
  this.jumpSound = this.sound.add('jump', { volume: 0.75 });
  this.hitSound = this.sound.add('hit', { volume: 0.75 });
  this.reachSound = this.sound.add('reach', { volume: 0.75 });
}

// Define all the animations from the previously loaded spritesheets
function createAnims() {
  this.anims.create({
    key: 'dino-waiting',
    frames: this.anims.generateFrameNumbers('dino', { start: 0, end: 1 }),
    frameRate: 1,
    repeat: -1
  })

  this.anims.create({
    key: 'dino-run',
    frames: this.anims.generateFrameNumbers('dino', { start: 2, end: 3 }),
    frameRate: 10,
    repeat: -1
  })

  this.anims.create({
    key: 'enemy-bird-anim',
    frames: this.anims.generateFrameNumbers('enemy-bird', { start: 0, end: 1 }),
    frameRate: 6,
    repeat: -1
  })
}

Creating the scores, the clouds, the dino, and the game over message (create.js)

  • Scores: Two boxes are used to print both the current and highest scores on the right hand side of the screen.
  • Clouds: A loop will be used to create 3 clouds at once printing the same image on random positions.
  • Dino: The dino will collide with the ground below, and it will jump using the keys defined here (in our case we will use both the up cursor key and the space bar). The mouse functionality is already available by default.
  • Game over: A message will be shown when the user collides with either the birds or the cactuses. After the user clicks on it, this message will be hidden using the “setAlpha(0)” function call.
// Score text on the right side of the screen
function createScore() {
  this.scoreText = this.add.text(WIDTH - 25, HEIGHT / 3, "", TEXT_STYLE).setOrigin(1, 1);
  this.highScoreText = this.add.text(0, HEIGHT / 3, "", TEXT_STYLE).setOrigin(1, 1).setAlpha(0.75);
}

// Three clouds hidden at the beginning
function createClouds() {
  this.clouds = this.add.group();
  for (let i = 0; i < 3; i++) {
    const x = Phaser.Math.Between(0, WIDTH);
    const y = Phaser.Math.Between(HEIGHT / 3, HEIGHT / 2);
    this.clouds.add(this.add.image(x, y, 'cloud'));
  }
  this.clouds.setAlpha(0);
}

// Dino starts running and the the ground is created
function createGround() {
  this.startText.destroy();
  this.dino.setVelocityX(100);
  this.dino.play('dino-run', 1);

  const interval = setInterval(() => {
    this.ground.width += (WIDTH / 100);
    if (this.ground.width >= WIDTH) {
      clearInterval(interval);
      this.dino.setVelocityX(0);
      this.scoreText.setAlpha(1);
      this.clouds.setAlpha(1);
      this.isGameRunning = true;
    }
  }, 50);
}

// Dino waiting animation, keyboard initilization and starting text
function createDino() {
  this.dino = this.physics.add.sprite(0, HEIGHT / 1.5, 'dino-idle').setSize(50).setGravityY(5000).setOrigin(0, 1);
  this.dino.play('dino-waiting', true);

  this.physics.add.collider(this.dino, this.belowGround);
  this.physics.add.collider(this.dino, this.obsticles, () => stopGame.call(this));

  this.cursors = this.input.keyboard.createCursorKeys();
  this.spaceBar = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);

  this.startText = this.add.text(WIDTH - 15, HEIGHT / 1.5, "Press to play", TEXT_STYLE).setOrigin(1);
}

// Game over screen and pause until player starts the game again
function createGameOverScreen() {
  this.gameOverText = this.add.image(0, 0, 'game-over');
  this.restart = this.add.image(0, 50, 'restart').setInteractive();
  this.gameOverScreen = this.add.container(WIDTH / 2, HEIGHT / 2 - 50);
  this.gameOverScreen.add([this.gameOverText, this.restart]);

  this.input.keyboard.on('keydown_ENTER', () => startGame.call(this));
  this.restart.on('pointerdown', () => startGame.call(this));
}

// Define the physical ground and obsticles group
function createWorld() {
  this.ground = this.add.tileSprite(0, HEIGHT / 1.5, 88, 26, 'ground').setOrigin(0, 1);
  this.belowGround = this.add.rectangle(0, HEIGHT / 1.5, WIDTH, HEIGHT / 3).setOrigin(0, 0);
  this.physics.add.existing(this.belowGround);
  this.belowGround.body.setImmovable();
  this.obsticles = this.physics.add.group();
}

// Create all the elements in the game and initilize the score
function create() {
  initVariables.call(this);
  initSounds.call(this);
  createAnims.call(this);
  createWorld.call(this);
  createClouds.call(this);
  createScore.call(this);
  createDino.call(this);

  setInterval(() => updateScore.call(this), 100);
}

Controlling the player and displaying the birds and the cactuses (update.js)

  • Starting the game and building the ground: The “createGround()” method will build the ground progresively, just once, when the game is started the very first time (after the user taps the screen or presses either the up arrow key or the space bar).
  • Restarting the game: After the game over message appears, the user may click on the screen to start a new game. This is done inside the “startGame()” method, which clears the message using the “destroy()” method call, restarts the score and resumes all the physics.
  • Updating the scores: The function “updateScore()” will not only update the current score, but it will also increment the game speed, and it will play a sound each time the user reaches 100 points, making the numbers blink at the same time. The “updateHighScore()” function will keep a record of the highest score each time a game is over.
  • Placing and moving the birds and the cactuses: The “placeObsticle()” function will create either birds or cactuses randomly, and the “update()” function will move everything to the left except the dino, which will always remain in the same position.
// Restart the game after dino is hurt
function startGame() {
  this.dino.setVelocityY(0);
  this.physics.resume();
  this.anims.resumeAll();
  this.obsticles.clear(true, true);
  this.gameOverScreen.destroy();
  this.isGameRunning = true;
}

// On game over a sound is played and all physics are paused
// The game over screen will be shown and mobile devices will vibrate
function stopGame() {
  initVariables.call(this);
  updateHighScore.call(this);
  this.hitSound.play();
  this.physics.pause();
  this.anims.pauseAll();
  this.dino.setTexture('dino-hurt');
  createGameOverScreen.call(this);
  if (window.navigator.vibrate) window.navigator.vibrate(50);
}

// Update the score and increase the game speed
function updateScore() {
  if (!this.isGameRunning) return;

  this.score++;
  this.gameSpeed += 0.01;

  if (this.score % 100 == 0) {
    this.reachSound.play();

    this.tweens.add({
      targets: this.scoreText,
      duration: 100,
      repeat: 3,
      alpha: 0,
      yoyo: true
    })
  }

  this.scoreText.setText(("0000" + this.score).slice(-5));
}

// Update the high score in case is higher than the previous one
function updateHighScore() {
  this.highScoreText.x = this.scoreText.x - this.scoreText.width - 30;

  const highScore = this.highScoreText.text.substr(this.highScoreText.text.length - 5);
  const newScore = Number(this.scoreText.text) > Number(highScore) ? this.scoreText.text : highScore;

  this.highScoreText.setText(`HI ${newScore}`);
}

// Place a new obsticle on the screen (either cactuses or birds)
function placeObsticle() {
  let obsticle;
  const obsticleNum = Phaser.Math.Between(1, 7);

  if (obsticleNum > 6) {
    obsticle = this.obsticles.create(WIDTH, HEIGHT / 1.5 - Phaser.Math.Between(20, 50), 'enemy-bird');
    obsticle.body.height /= 1.5
    obsticle.play('enemy-bird-anim', 1);
  } else {
    obsticle = this.obsticles.create(WIDTH, HEIGHT / 1.5, `obsticle-${obsticleNum}`)
  }

  obsticle.setOrigin(0, 1).setImmovable();
}

// Update the screen (obsticles, ground, clouds and dino) and check the keyboard and mouse (or touch screen) continuously
// Dino will start running and jump if the user taps the screen or presses either the arrow up key or the spacebar  
function update() {
  let jump = this.cursors.up.isDown || this.spaceBar.isDown || this.input.activePointer.isDown;

  if (this.isGameRunning) {
    this.ground.tilePositionX += this.gameSpeed;

    this.respawnTime += this.gameSpeed;
    if (this.respawnTime >= OBSTICLE_TIME) {
      this.respawnTime = 0;
      placeObsticle.call(this);
    }

    this.obsticles.getChildren().forEach(obsticle => {
      obsticle.x -= this.gameSpeed;
      if (obsticle.getBounds().right < 0) {
        obsticle.destroy();
      }
    })

    this.clouds.getChildren().forEach(cloud => {
      cloud.x -= 0.5;
      if (cloud.getBounds().right < 0) {
        cloud.x = WIDTH;
      }
    })

    if (this.dino.body.onFloor()) {
      if (jump) {
        this.jumpSound.play();
        this.dino.anims.stop();
        this.dino.setVelocityY(-1600);
        this.dino.setTexture('dino', 0);
      }
      else {
        this.dino.play('dino-run', true);
      }
    }
  }
  else if (jump && (this.ground.width < WIDTH)) {
    createGround.call(this);
  }
}

Proposed exercise: Using your own assets

Create a new version of the Dino game using your own assets (images, sprites and sounds). You can also change any other things you like.

Do not forget to create all the files required to run the code in this unit:

  • styles.css
  • index.html
  • preload.js
  • create.js
  • update.js
  • config.js
  • assets folder

You may find many free assets in OpenGameArt, and some new versions of the Dino game in github:

Enjoy the game!

You can enjoy playing this wonderful game online here.

Phaser. Unit 6. Using tilemaps to build a space shooter game

The source code and the assets

You can download this zip file to get the source code and all the assets.

Updating the map

As done previously, you can use the tile map editor to update the map. In case you have not done it yet, you can download the editor from this link and install it on your computer.

To update the map you have to go the assets folder and open the file “town.tmx” which contains the map of the example in this unit. Select a layer (on the right corner, at the top) and change anything you like (by adding or removing some objects to or from the map). When you finish, export the map to JSON format using the option “File” -> “Export as…”, and overwrite the file “town.json”, located inside the “assets” folder.

Finally check the results using your browser and move the player to go to each part of the map. You will notice that the camera will follow you on each movement. Do not forget to fully refresh the contents of the browser by pressing Ctrl+F5, since the map and some other contents are usually cached by the browser. Also move the player and check that the map has been updated with the changes you have made.

Frames per second (FPS)

You can update the number of frames per second to choose the best renderization performance for your computer:

const MAX_FPS = 25;

Using the keyboard and the mouse

In this game we are using the keys A (left), S (down), D (right), W (up) to move the player on desktop computers and the joystick for mobile devices:

  let vX = 0, vY = 0;

  // Horizontal movement
  if (wsadKeys.A.isDown || joyStick.left) vX = -speed;
  else if (wsadKeys.D.isDown || joyStick.right) vX = speed;

  // Vertical movement
  if (wsadKeys.W.isDown || joyStick.up) vY = -speed;
  else if (wsadKeys.S.isDown || joyStick.down) vY = speed;

The mouse is used for shooting on desktop computers:

  let pointer = this.input.activePointer;
  if (pointer.isDown && this.sys.game.device.os.desktop) createBullet.call(this, pointer.worldX - player.x, pointer.worldY - player.y);

In case you do not have any mouse, you may use the cursor keys on desktop computers, or the second joystick on mobile devices, to shoot the bullets and to choose the shooting direction:

  let vX = 0, vY = 0;

  // Horizontal bullets
  if (cursors.left.isDown || joyStick2.left) vX = -speed;
  else if (cursors.right.isDown || joyStick2.right) vX = speed;

  // Vertical bullets
  if (cursors.up.isDown || joyStick2.up) vY = -speed;
  else if (cursors.down.isDown || joyStick2.down) vY = speed;

  if (vX || vY) createBullet.call(this, vX, vY);

Enjoy the game!

You may enjoy playing this wonderful game online here.

Phaser. Unit 5. Using tilemaps to build a shooter game in a huge world

A preview of the map

You can have a look at this preview of the whole map.

The source code and the assets

You can download this zip file to get the source code and all the assets.

Updating the map

As done previously, you can use the tile map editor to update the map. In case you have not done it yet, you can download the editor from this link and install it on your computer.

To update the map you have to go the assets folder and open the file “town.tmx” which contains the map of the example in this unit. Select a layer (on the right corner, at the top) and change anything you like (by adding or removing some objects to or from the map). When you finish, export the map to JSON format using the option “File” -> “Export as…”, and overwrite the file “town.json”, located inside the “assets” folder.

Since we are working with a lot of tilesets, in this case we are setting the collisions with all the objects in some of the layers: “Terrain_3”, “Buildings_1” and “Buildings_2”. The player and the enemies will collide with all the objects that you drawn in these three layers.

Finally check the results using your browser and move the player to go to each part of the map. You will notice that the camera will follow you on each movement. Do not forget to fully refresh the contents of the browser by pressing Ctrl+F5, since the map and some other contents are usually cached by the browser. Also move the player and check that the map has been updated with the changes you have made.

Renderization problems

Due to the large amount of resources this game is going to use from your computer, you might need to adjust a couple of parameters. It seems it works fine on Chrome browser, but if you are using another browser, or in case you find any problems, you can try updating the following parameters.

Phaser renderer

I have selected “Phaser.CANVAS” inside the phaser configuration settings, which seems to work much better on Chrome browser, but you may try “Phaser.AUTO” instead:

const config = {
  type: Phaser.AUTO
  ...
}

Frames per second (FPS)

You can also change the number of frames per second to choose the right renderization performance:

const MAX_FPS = 25;

Using the keyboard and the mouse

In this game we are using the keys A (left), S (down), D (right), W (up) to move the player on desktop computers and the joystick for mobile devices:

  let vX = 0, vY = 0;

  // Horizontal movement
  if (wsadKeys.A.isDown || joyStick.left) vX = -speed;
  else if (wsadKeys.D.isDown || joyStick.right) vX = speed;

  // Vertical movement
  if (wsadKeys.W.isDown || joyStick.up) vY = -speed;
  else if (wsadKeys.S.isDown || joyStick.down) vY = speed;

The mouse is used for shooting on desktop computers:

  let pointer = this.input.activePointer;
  if (pointer.isDown && this.sys.game.device.os.desktop) createBullet.call(this, pointer.worldX - player.x, pointer.worldY - player.y);

In case you do not have any mouse, you may use the cursor keys on desktop computers, or the second joystick on mobile devices, to shoot the bullets and to choose the shooting direction:

  let vX = 0, vY = 0;

  // Horizontal bullets
  if (cursors.left.isDown || joyStick2.left) vX = -speed;
  else if (cursors.right.isDown || joyStick2.right) vX = speed;

  // Vertical bullets
  if (cursors.up.isDown || joyStick2.up) vY = -speed;
  else if (cursors.down.isDown || joyStick2.down) vY = speed;

  if (vX || vY) createBullet.call(this, vX, vY);

Enjoy the game!

You may enjoy playing this wonderful game online here.

Phaser. Unit 4. Using tilemaps to build a pacman game

Introduction

In this unit we are going to use Phaser together with a tilemap editor to build a maze. You may find more information at the official page of the Tiled editor and you may also have a look at some interesting demonstrations at the Phaser’s examples page, and also performing some search on the latest examples.

HTML and CSS code

The first thing we should do is linking the Phaser library. We will use the last version at the time this unit has been written:

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>

After that we are going to insert some CSS code to remove any margin and padding, and also to remove the scrollbars, so that all the window size is used when the game is started:

html, body {
    margin: 0px;
    padding: 0px;
    overflow: hidden;
    height: 100%;
} 

And finally we are going to use a single file to put all the code inside. This is going to be the basic structure of the “.html” file:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <title>First game with Phaser 3</title>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>
    <style type="text/css">
        html, body {
            margin: 0px;
            padding: 0px;
            overflow: hidden;
            height: 100%;
        }
    </style>
</head>
<body>
    <script type="text/javascript">
        ...
    </script>
</body>
</html>

Constants

We are going to define some constants at the beginning of the code so that we can easily change the number of coins, fruits and ghosts that will be created:

const numCoins = 10;
const numFruits = 5;
const numGhosts = 5;

Phaser configuration and other variables

Following the constants we are going to create some variables so keep all the information about the game and also to start the initialization of Phaser:

const config = {
    type: Phaser.AUTO,
    pixelArt: true,
    scale: {
        mode: Phaser.Scale.ENVELOP,
        width: window.innerWidth,
        height: window.innerHeight
    },
    physics: {
        default: "arcade",
        arcade: {
            gravity: { y: 0 }
        }
    },
    scene: {
        preload: preload,
        create: create,
        update: update
    }
};

const game = new Phaser.Game(config);
let speed = 60, vX = 0, vY = 0, prevX = 0, prevY = 0, prevTime = 0;
let belowLayer, worldLayer, aboveLayer, tileset, emptyTiles;
let map, player, cursors, bell, bell2, dead, hurt, timeout;
let joyStick = joyStick2 = { up: false, down: false, left: false, right: false };
let score = 0, lives = 5, scoreText = null, gameOver = false;

The assets (images, sprites and sounds)

We will need some images, sprites and sounds. You may use any assets you like, but we are also providing some examples so that you can easily start testing (they can also be downloaded here):

Images

Tileset containing almost every image needed to build the game
Restart button

Sounds and music

When the player gets a coin
When the user collects a fruit
When the user gets hurt by a ghost
When the user kills a ghost
Background music always playing

Loading all the assets

So that we can use all those images, sprites and music, they have to be loaded previously. That is the purpose of the “preload()” function. We will also include here the initialization of the joystick so that we can use it in another section of this unit.

Proposed exercise: Game initialization

Create a specific folder for your game in your domain to put all the code and assets inside. After that, create an “index.html” inside that folder with the code below, and also create an “assets” folder to put all the images and sounds, which can be downloaded here. After uploading everything to your domain, test the game using the url of that folder in your browser, and you should get a full black screen. Click on it and the background music should start playing.

You can see the result here (you can also look at the source code by pressing Ctrl+U).
<!doctype html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <title>First game with Phaser 3</title>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>
    <style type="text/css">
        html,
        body {
            margin: 0px;
            padding: 0px;
            overflow: hidden;
            height: 100%;
        }
    </style>
</head>

<body>
    <div id="game-container"></div>
    <script type="text/javascript">
        const numCoins = 10;
        const numFruits = 5;
        const numGhosts = 5;

        const config = {
            type: Phaser.AUTO,
            pixelArt: true,
            scale: {
                mode: Phaser.Scale.ENVELOP,
                width: window.innerWidth,
                height: window.innerHeight
            },
            physics: {
                default: "arcade",
                arcade: {
                    gravity: { y: 0 }
                }
            },
            scene: {
                preload: preload,
                create: create,
                update: update
            }
        };

        const game = new Phaser.Game(config);
        let speed = 60, vX = 0, vY = 0, prevX = 0, prevY = 0, prevTime = 0;
        let belowLayer, worldLayer, aboveLayer, tileset, emptyTiles;
        let map, player, cursors, bell, bell2, dead, hurt, timeout;
        let joyStick = joyStick2 = { up: false, down: false, left: false, right: false };
        let score = 0, lives = 5, scoreText = null, gameOver = false;

        function preload() {
            this.load.image("restart", "assets/restart.png");
            this.load.image("tiles", "assets/tileset.png");
            this.load.tilemapTiledJSON("map", "assets/map.json");
            this.load.spritesheet("sprites", "assets/tileset.png", { frameWidth: 20, frameHeight: 20, margin: 1, spacing: 1 });

            this.load.audio("bell", "assets/ding.mp3");
            this.load.audio("bell2", "assets/ding2.mp3");
            this.load.audio("dead", "assets/dead.mp3");
            this.load.audio("hurt", "assets/hurt.mp3");
            this.load.audio("music", "assets/music.mp3");

            this.load.plugin('rexvirtualjoystickplugin', 'https://cdn.jsdelivr.net/npm/[email protected]/dist/rexvirtualjoystickplugin.min.js', true);
        }

        function initSounds() {
            bell = this.sound.add('bell', { volume: 0.2 });
            bell2 = this.sound.add('bell2', { volume: 0.8 });
            dead = this.sound.add('dead', { volume: 0.7 });
            hurt = this.sound.add('hurt', { volume: 0.9 });
            this.sound.add('music', { volume: 0.4 }).play({ loop: -1 });
        }

        function create() {
            initSounds.call(this);
        }

        function update(time, delta) {
        }
    </script>
</body>

</html>

Proposed exercise: Using your own music

Download any other music file you like to use it as a background music and change the JavaScript code inside the “preload()” function to set the file name accordingly. Also in the “initSounds()” function you will find that you may change the volume of the music (volume:0.4). Try changing that value from 0.1 to 1 and you will notice that the volume of the music will also change.

You may find in the following sites some free images and sounds which you can download and use for your own game:

The maze

Once we have initialized Phaser and after loading all the assets, we can go ahead and display the maze on the screen.

Proposed exercise: Displaying the maze

Add the function “createWorld()” (provided below) to your code, and modify the calling function “create()” so that it is called when the game is started (also shown below). Finally test everything in your browser from your domain.

About the music, do not forget to click on the screen after loading the game (the music will not start playing unless the browser gets the focus). Also, you can see the result here (you can look at the source code by pressing Ctrl+U).
  1. This is the new function you have to insert in your code to create the world:
function createWorld() {
    map = this.make.tilemap({ key: "map" });

    // Parameters are the name you gave the tileset in Tiled and then the key of the tileset image in
    // Phaser's cache (i.e. the name you used in preload)
    tileset = map.addTilesetImage("tileset", "tiles");

    // Parameters: layer name (or index) from Tiled, tileset, x, y
    belowLayer = map.createLayer("Below Player", tileset, 0, 0).setDepth(1);
    worldLayer = map.createLayer("World", tileset, 0, 0).setDepth(2);
    aboveLayer = map.createLayer("Above Player", tileset, 0, 0).setDepth(3);

    worldLayer.setCollisionByProperty({ collides: true });

    this.physics.world.setBounds(0, 0, map.widthInPixels, map.heightInPixels);

    // Find empty tiles where new zombies, coins or health can be created
    emptyTiles = worldLayer.filterTiles(tile => (tile.index === -1));

    this.scale.resize(map.widthInPixels, map.heightInPixels).refresh();
}
  1. Do not forget to update the “create()” function to create the world. This is the only line you should add to that function:
function create() {
    ...
    createWorld.call(this);            
}

Proposed exercises: Modifying the maze

Download the tile map editor from this link and install it on your computer (if you don’t have installed it yet). Go to the assets folder and open the file “map.tmx” which contains the map of the example explained in this unit. Select the layer “World” (on the right corner, at the top) and change anything you like (by adding or removing some objects to or from the map). When you finish, export the map to JSON format using the option “File” -> “Export as…”, and overwrite the file “map.json”, located inside the “assets” folder. Finally check the results using your browser.

Do not forget to fully refresh the contents of the browser by pressing Ctrl+F5, since the map and some other contents are usually cached by the browser. Then you can check that the map has been updated with the changes you have made.

The animations (sprites)

We will use sprites to create the player and the animated objects. Let’s have a look at the the picture containing all the sprites:

Tileset containing all the sprites

We may appreciate that we have several groups of images. This way, we may easily create the animations:

  • The coin: Pictures from 0 to 7
  • The point: Picture 8
  • The ghosts:
    • Blue: Pictures from 17 to 20
    • Pink: Pictures from 21 to 24
    • Yellow: Pictures from 25 to 28
    • Green: Pictures from 34 to 37
    • Orange: Pictures from 38 to 41
    • Red: Pictures from 42 to 45
  • The player:
    • Left: Pictures from 51 to 58
    • Right: Pictures from 68 to 75
    • Down: Pictures from 85 to 92
    • Up: Pictures from 102 to 109
  • The fruits: Pictures 46, 47, 114, 143

Controlling the player’s animations

So that this game can be used in both desktop and mobile devices, we have to initialize both the cursor keys and the joystick. Once this is done, we must change the player’s velocity and the current animation when the user presses the keys or moves the joystick. In the next exercise we will first define the animations inside the “createAnimations()” function, and after that we will go ahead with the “createPlayer()” function, where we will display the player. We will finally include in the “update()” function some conditions to adjust the player’s speed and current animation.

Proposed exercise: Adding the player

Add the code below to your game and check both the player animations and movements. After that, look for another sprite and change the code accordingly to use your own sprite. Finally, change the values of the constant “speed” (at the top of your code) and check the results.

You can see the result here (you can look at the source code by pressing Ctrl+U). Also, as listed above, you may find many free assets in OpenGameArt and choose other sprites you like.
  1. These are the new functions you have to insert in your code to create the animations and initialize everything related to the player:
function newAnimation(name, rate, sprites, context) {
    context.anims.create({
        key: name, frameRate: rate, repeat: -1,
        frames: context.anims.generateFrameNumbers('sprites', sprites),
    });
}

function createAnimations() {
    // Player
    newAnimation("left", 10, { start: 51, end: 58 }, this);
    newAnimation("right", 10, { start: 68, end: 75 }, this);
    newAnimation("down", 10, { start: 85, end: 92 }, this);
    newAnimation("up", 10, { start: 102, end: 109 }, this);
    // Point
    newAnimation("point", 1, { start: 8, end: 8 }, this);
    // Coin
    newAnimation("spinning", 10, { start: 0, end: 7 }, this);
    // Fruits
    newAnimation("fruits", 2, { frames: [46, 47, 114, 143] }, this);
    // Ghosts
    newAnimation("blue", 5, { start: 17, end: 20 }, this);
    newAnimation("pink", 5, { start: 21, end: 24 }, this);
    newAnimation("yellow", 5, { start: 25, end: 28 }, this);
    newAnimation("green", 5, { start: 34, end: 37 }, this);
    newAnimation("orange", 5, { start: 38, end: 41 }, this);
    newAnimation("red", 5, { start: 42, end: 45 }, this);
}

function createPlayer() {
    // Object layers in Tiled let you embed extra info into a map - like a spawn point or custom
    // collision shapes. In the tmx file, there's an object layer with a point named "Spawn Point"
    const spawnPoint = map.findObject("Objects", obj => obj.name === "Spawn Point");

    // Create a sprite with physics enabled via the physics system
    player = this.physics.add.sprite(spawnPoint.x, spawnPoint.y, "sprites").setDepth(2);

    // Watch the player and worldLayer for collisions, for the duration of the scene
    this.physics.add.collider(player, worldLayer);

    // Show points on empty tiles
    emptyTiles.forEach(tile => {
        let point = this.physics.add.sprite(tile.pixelX, tile.pixelY, 'sprites').setOrigin(0).anims.play('point', true);
        this.physics.add.overlap(player, point, collectPoint, null, this);
    });

    cursors = this.input.keyboard.createCursorKeys();

    // Show the joysticks only in mobile devices
    if (!this.sys.game.device.os.desktop) {
        joyStick = this.plugins.get('rexvirtualjoystickplugin').add(this, {
            x: 380, y: 420, radius: 20,
            base: this.add.circle(0, 0, 20, 0x888888).setAlpha(0.5).setDepth(4),
            thumb: this.add.circle(0, 0, 20, 0xcccccc).setAlpha(0.5).setDepth(4)
        }).on('update', update, this);

        joyStick2 = this.plugins.get('rexvirtualjoystickplugin').add(this, {
            x: 40, y: 420, radius: 20,
            base: this.add.circle(0, 0, 20, 0x888888).setAlpha(0.5).setDepth(4),
            thumb: this.add.circle(0, 0, 20, 0xcccccc).setAlpha(0.5).setDepth(4)
        }).on('update', update, this);
    }
}

function showScore() {
    if (!scoreText) scoreText = this.add.text(map.widthInPixels/2, 4, '', { fontSize: (18) + 'px', fill: '#FFF' }).setOrigin(0.5, 0).setScrollFactor(0).setDepth(4);
    scoreText.setText('Score:' + score + ' / Lives:' + lives);
}

function collectPoint(player, point) {
    bell.play();
    point.destroy();

    score += 5;
    showScore();
}
  1. Also you have to insert some code inside the “update()” function to respond to the cursor keys and the joystick:
function update(time, delta) {
    if (gameOver) return;

    // Choose the right animation depending on the player's direction
    if (player.x > prevX) player.anims.play('right', true);
    else if (player.x < prevX) player.anims.play('left', true);
    else if (player.y > prevY) player.anims.play('down', true);
    else if (player.y < prevY) player.anims.play('up', true);

    // If the player goes outside the map
    if (player.x < 0) player.x = map.widthInPixels - 20;
    else if (player.x > map.widthInPixels) player.x = 0;

    let key = player.anims.currentAnim.key;
    let blocked = player.body.blocked;

    // Reset the velocity when the player touches a wall
    if (key == 'right' && blocked.right || key == 'left' && blocked.left) vX = 0;
    if (key == 'up' && blocked.up || key == 'down' && blocked.down) vY = 0;

    // Horizontal movement
    if (cursors.left.isDown || joyStick.left || joyStick2.left) vX = -speed;
    else if (cursors.right.isDown || joyStick.right || joyStick2.right) vX = speed;

    // Vertical movement
    if (cursors.up.isDown || joyStick.up || joyStick2.up) vY = -speed;
    else if (cursors.down.isDown || joyStick.down || joyStick2.down) vY = speed;

    player.setVelocity(vX, vY);

    if ((time - prevTime) > 100) {
        prevX = player.x;
        prevY = player.y;
        prevTime = time;
    }
}
  1. And finally do not forget to update the “create()” function to create the animations, the player and the score. You just have to insert some new lines:
function create() {
    ...
    createAnimations.call(this); 
    createPlayer.call(this);
    showScore.call(this);
}

Coins, fruits, and ghosts

We will create many different objects at once using the images from the same tileset. We will just use a loop to print as many objects as we like.

Proposed exercises: Adding the coins

Add the code below to your file and check the results. After that, change the value of the constant “numCoins” (at the top of your code) to check how easily you can customize your game. Refresh the page several times and you will notice that the generated coins appear at random positions. Finally, look for another sprite sheet (it may be a coin or any other object you like) and change the code accordingly to use your own animated object.

You can see the result here (you can look at the source code by pressing Ctrl+U). Also, as listed above, you may find many free assets in OpenGameArt and choose the images you like.
function newObject(animation, context) {
    const tile = Phaser.Utils.Array.GetRandom(emptyTiles);
    return context.physics.add.sprite(tile.pixelX, tile.pixelY, 'sprites').setOrigin(0).anims.play(animation, true);
}

function createCoin() {
    let coin = newObject('spinning', this);
    coin.body.setAllowGravity(false);
    this.physics.add.overlap(player, coin, collectCoin, null, this);
}

function protect(color) {
    player.setTint(color);
    if (timeout) clearTimeout(timeout);
    timeout = setTimeout(() => { timeout = false; player.clearTint() }, 5000);
}

function collectCoin(player, coin) {
    bell2.play();
    coin.destroy();
    createCoin.call(this);

    score += 10;
    showScore();

    protect(0xFFFF00);
}
function create() {
    ...
    for (i = 0; i < numCoins; i++) setTimeout(() => createCoin.call(this), Phaser.Math.Between(0, 5000));
}

Proposed exercises: Adding the fruits

Add the code below to your game and check the results. After that, change the value of the constants “numFruits” (at the top of your code) to check how easily you can customize your game. Finally, look for another sprite and change the code accordingly to use your own object to provide the player with extra lives and points.

You may click here to see how the game looks like. As listed above, you may find many free assets in OpenGameArt.
function createFruits() {
    let fruits = newObject('fruits', this);
    fruits.body.setAllowGravity(false);
    this.physics.add.overlap(player, fruits, collectFruits, null, this);
}

function collectFruits(player, fruits) {
    bell2.play();
    fruits.destroy();
    createFruits.call(this);

    score += 15;
    showScore();
}
function create() {
    ...
    for (i = 0; i < numFruits; i++) setTimeout(() => createFruits.call(this), Phaser.Math.Between(0, 5000));
}

Proposed exercises: Adding the ghosts

Add the code below to your game and check the results. After that, change the value of the constants “numGhosts” (at the top of your code) to check how easily you can customize your game. Finally, look for another sprite and change the code accordingly to display your own ghosts.

You may click here to see how the game looks like. As listed above, you may find many free assets in OpenGameArt.
function newGhost(animation, context) {
    const spawnPoint = map.findObject("Objects", obj => obj.name === "Spawn Point 2");
    return context.physics.add.sprite(spawnPoint.x, spawnPoint.y, 'sprites').setOrigin(0).anims.play(animation, true).setDepth(2);
}

function createGhost() {
    const colors = ['blue', 'pink', 'yellow', 'green', 'orange', 'red'];
    let ghost = newGhost(colors[Phaser.Math.Between(0, colors.length - 1)], this).setCollideWorldBounds(true).setBounce(1);
    ghost.setVelocity(Phaser.Math.Between(speed / 3, speed / 2), Phaser.Math.Between(-speed / 2, -speed / 3)).body.setAllowGravity(false);
    this.physics.add.collider(ghost, worldLayer);
    this.physics.add.overlap(player, ghost, hitGhost, null, this);
}

function hitGhost(player, ghost) {
    // If the player is already hurt, it cannot be hurt again for a while
    if (player.tintTopLeft == 0xFF00FF) return;

    if (timeout) {
        score += 15;
        dead.play();
    }
    else {
        lives--;
        hurt.play();
        protect(0xFF00FF);
    }

    showScore();

    if (lives == 0) {
        this.physics.pause();
        gameOver = true;
        this.add.image(210, 230, 'restart').setScale(2).setScrollFactor(0).setDepth(4)
            .setInteractive().on('pointerdown', () => location.reload());
    }
    else {
        ghost.destroy();
        createGhost.call(this);
    }
}  
function create() {
    ...
    for (i = 0; i < numGhosts; i++) setTimeout(() => createGhost.call(this), Phaser.Math.Between(0, 15000)); 
}

Enjoy the game!

You may enjoy playing this wonderful game online here.