Teflonhjerne – hvordan lage et "memory game" i Javascript

Teflonhjerne er et ganske enkelt spill, men med litt animasjoner, lydeffekter og forskjellig så ble sluttresultatet ganske underholdende.

Etter å ha holdt på med React en stund, var det moro å leke seg litt med vanilla Javascript igjen. Resultatet ble et enkelt huskespill – Teflonhjerne.

Spillet er laget uten bruk av noen rammeverk eller biblioteker, men jeg valgte å bruke Vite som byggeverktøy. Jeg sa riktignok "vanilla Javascript", men jeg fant ut at jeg ville lage det i TypeScript – så det riktige er vel å si nesten vanilla Javascript. 🤓

Reglene i spillet er enkle:

  • Klikk et kort (rute) for å se hvilket symbol som skjuler seg bak.
  • Prøv å matche to og to like, og se hvor mange forsøk du trenger for å matche alle bildene på hele brettet.

Du kan prøve spillet her: https://teflonhjerne.netlify.app

Sette opp prosjektet

Som nevnt brukte jeg byggeverktøyet Vite. Siden dette er et ganske enkelt spill kunne jeg forsåvidt klart meg fint uten noe byggeverktøy og all Javascript-koden i én fil. Men jeg valgte Vite fordi det åpnet for mer avanserte muligheter i tilfelle jeg ville gjøre noe mer avansert senere, og så kommer det med støtte for TypeScript og mye annet rett ut av boksen.

Jeg opprettet prosjektet i Vite slik:

npm create vite@latest

Så var det bare å følge instruksjonene. Vite spør deg blant annet om du vil at prosjektet skal opprettes som vanilla Javascript, React, Vue eller annet, og velger du vanilla Javascript blir du også spurt om du vil ha TypeScript.

Har du ikke prøvd Vite, anbefales det på det varmeste. Det er lynkjapt, enkelt å bruke, og kommer med en masse funksjonalitet innebygget. Det er en grunn til at stadig flere nå bytter fra Webpack til Vite.

Standard mappestruktur for det nye Vite-prosjektet så slik ut:

1.
2├── favicon.svg
3├── index.html
4├── package.json
5├── public
6├── src
7│   ├── main.ts
8│   ├── style.css
9└── tsconfig.json

./src/main.ts er hovedfilen for Javascript-koden. Siden Teflonhjerne er et enkelt spill, gjorde jeg ikke noe særlig med mappestrukturen. Men jeg opprettet en lib-mappe inne i src til noen filer for blant annet å holde styr på state for spillet, pekere til grafikk til alle spillerutene, samt lydeffekter. Jeg lagde også undermappene audio, icons og images under public-mappen.

Jeg ville at spillet skulle være mulig å kjøre også i "offline-modus", og fungere som en frittstående applikasjon på mobiltelefoner. Derfor installerte jeg også en Vite-plugin som heter Vite Plugin PWA, som gjør det enklere å gjøre applikasjonen til en såkalt progressiv web-app. Det betyr kort fortalt at hele appen, inkludert grafikk, lyd, osv. caches og dermed vil fungere uten nett. I Chrome og på mobil vil du få spørsmål om du ønsker å installere appen på telefonen, og da vil den kjøres uten nettlesermenyene – og ligne på en native mobilapp. Jeg skal ikke gå i detalj på dette i denne artikkelen, men det innebærer blant annet å lage et manifest med en del informasjon i, samt ikoner og skjermbilder i ulike størrelser (her hadde jeg god hjelp av blant annet maskable.app til å lage ikoner for mobil, samt Frontend Masters-kurset "Build Progressive Web Apps (PWAs) from Scratch").

Spill-logikken

I første versjon av spillet la jeg alle spillekortene i DOM-en, og brukte CSS-selektorer for å vise eller skjule symbolene på kortene. Problemet med den tilnærmingen er at det er ekstremt lett å jukse, det er bare å åpne utviklerverktøyet i nettleseren og inspisere DOM-elementene for hvert kort.

Her var det nødvendig å tenke annerledes! Jeg valgte å opprette en global konstant kalt gameState, som er et Javascript-objekt som inneholder blant annet et array med spillekort-objekter, der hvert spillekort-objekt har en src til bilde, samt en isMatched-boolean som settes når et kort er matchet (spilleren har funnet to like kort).

1// ./src/lib/gameState.ts
2
3import { GameState } from '../../types';
4
5// Create a global object to store state
6// Could be useful for storing more stuff in one place if we later
7// wants to expand the game with more functionality
8export const gameState: GameState = {
9  tiles: [],
10  firstTileID: null,
11  firstTileDOMElement: null,
12  isBlocked: false,
13  tilesFlipped: 0,
14  tries: 0,
15  isMuted: false,
16  modalIsOpen: false,
17};

gameState.tiles i kodesnutten over inneholder foreløpig et tomt array, men vil bli populert med spillekort hentet fra TILES senere:

1// ./src/lib/tiles.ts
2export const TILES = [
3  { name: 'stekepanne', src: './images/1.svg', image: null, isMatched: false },
4  { name: 'gulrot', src: './images/3.svg', image: null, isMatched: false },
5  { name: 'pokal', src: './images/4.svg', image: null, isMatched: false },
6  { name: 'gitar', src: './images/5.svg', image: null, isMatched: false },
7  { name: 'snømann', src: './images/6.svg', image: null, isMatched: false },
8  { name: 'gris', src: './images/7.svg', image: null, isMatched: false },
9  { name: 'ugle', src: './images/8.svg', image: null, isMatched: false },
10  { name: 'chaplin', src: './images/9.svg', image: null, isMatched: false },
11  { name: 'kake', src: './images/10.svg', image: null, isMatched: false },
12  { name: 'hamburger', src: './images/11.svg', image: null, isMatched: false },
13  
14  // ...
15]

Da kan vi referere til de ulike spillekortene med for eksempel gameState.tiles[clickedTileID].name. I mappen ./public/images har jeg totalt 37 svg-er med ulike figurer som skal vises når spilleren klikker en rute.

Når spillet starter vises først en modal med instruksjoner for spillet. Javascript-koden for denne modalen befinner seg i en egen fil (modal.ts), så kan jeg importere den i main.ts for å bruke den der. Samme modal brukes også til å vise informasjon til spilleren når spillet er slutt.

1// ./src/main.ts
2import modal from './modal';
3
4modal({
5  title: 'Teflonhjerne',
6  body: 'Prøv å matche to og to figurer. Hvor mange forsøk trenger du for å klare hele brettet?',
7  buttonText: 'Start spillet',
8  modalBtnCB: newGame,
9});
Åpningsskjermbildet i Teflonhjerne.
Åpningsskjermbildet i Teflonhjerne.

Sette opp det nødvendige

I index.html tegner jeg opp skjermbildet med en tittel, knapp for å starte et nytt spill, samt en tekst som viser antall forsøk. Jeg oppretter en tom div med id gamegrid som er det stedet i DOM-en jeg senere skal tegne opp spillebrettet. Jeg laster også inn en fin Google-font.

1<!DOCTYPE html>
2<html lang="no">
3  <head>
4    <meta charset="UTF-8" />
5    <meta
6      name="viewport"
7      content="width=device-width, initial-scale=1.0, viewport-fit=cover"
8    />
9    <meta name="theme-color" content="#f6b445" />
10    <meta
11      name="description"
12      content="I Teflonhjerne er det om å gjøre å huske hvilke figurer som befinner seg i hver rute. Det er mulig å klare spillet på 8 forsøk, men da må du ha vanvittig med flaks - og vi vil heller anbefale Lotto."
13    />
14    <title>Teflonhjerne - memory game</title>
15
16    <link rel="preconnect" href="https://fonts.googleapis.com" />
17    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18    <link
19      href="https://fonts.googleapis.com/css2?family=Bangers&family=Open+Sans&display=swap"
20      rel="stylesheet"
21    />
22
23    <link rel="icon" href="/favicon.svg" />
24    <link rel="apple-touch-icon" href="/icon-512.png" />
25    <link rel="mask-icon" href="/icon-maskable.png" color="#FFFFFF" />
26
27    <script type="module" src="/src/main.ts"></script>
28  </head>
29  <body>
30    <div id="modal"></div>
31    <section class="main-container" hidden>
32      <div id="game-heading">
33         <!--
34          inline svg for animert Teflonhjerne-logo
35         -->
36        
37      </div>
38
39      <section id="gamestat-container">
40        <div id="button-container">
41          <button id="reset-button" class="btn-standard">
42            Start nytt spill
43          </button>
44          <button class="btn-square">
45            <img
46              id="audio-toggle"
47              src="./icons/volume-up-fill.svg"
48              alt="Lyd er på"
49              style="width: 2rem; height: 2rem"
50              aria-label="Lyd er på"
51            />
52          </button>
53        </div>
54        <div class="score-display" aria-label="Antall forsøk">
55          Forsøk: <span id="tries">0</span>
56        </div>
57      </section>
58
59      <section class="gamegrid-container">
60        <div id="gamegrid"></div>
61      </section>
62      <footer class="footer">
63        (C) <a href="https://www.lekanger.no">Kurt Lekanger</a> 2022
64      </footer>
65    </section>
66  </body>
67</html>
68

I main.ts lagrer jeg en referanse board til DOM-elementet med ID gamegrid på denne måten:

1const board = document.querySelector('#gamegrid');

Når spilleren klikker "Start spillet" kalles funksjonen newGame i main.ts. Den nullstiller spillet:

1// ./src/main.ts
2
3  function newGame() {
4    gameState.tries = 0;
5    gameState.modalIsOpen = false;
6
7    // Update gamestate with a new randomized tile set
8
9    gameState.tiles = shuffleTiles(TILES);
10
11    // Clear the board and scores display in the DOM
12    if (board instanceof HTMLElement) {
13      board.innerHTML = ''; // Clear board
14    }
15    if (triesDisplay instanceof HTMLElement) {
16      triesDisplay.innerText = '0'; // Reset tries to zero
17    }
18
19    drawEmptyBoard();
20  }

Hvis du lurer på hva som er poenget med instanceof-opplegget i koden over, så er det type guards for å unngå feilmeldinger i TypeScript. I vanilla JS kunne du droppet det. Uansett: På linje 9 oppdaterer vi gameState.tiles med et array med tilfeldige symboler ved å kalle funksjonen shuffleTiles med alle spillekortene:

1// ./src/main.ts
2
3function shuffleTiles(tiles: Tile[]) {
4  if (tiles.length < 8) {
5    throw new Error('Not enough tiles to shuffle');
6  }
7
8  const preloadImage = (tile: Tile) => {
9    const image = new Image();
10    image.src = tile.src;
11    image.alt = tile.name;
12    return image;
13  };
14
15  // Make a set of 8 random tiles from the larger array of tiles. Set makes sure we don't get duplicates
16  const randomSetOfTiles = new Set<Tile>();
17  while (randomSetOfTiles.size < 8) {
18    const randomIndex = Math.floor(Math.random() * tiles.length);
19    randomSetOfTiles.add(tiles[randomIndex]);
20  }
21
22  // Convert the set to an array and duplicate the tiles
23  const duplicatedTilesArray = [...randomSetOfTiles, ...randomSetOfTiles];
24
25  // Shuffle the array
26  duplicatedTilesArray.sort(() => Math.random() - 0.5);
27
28  // Preload images for better performance
29  const tilesWithPreloadedImages = duplicatedTilesArray.map((tile: Tile) => {
30    const image = preloadImage(tile);
31    return { ...tile, image };
32  });
33
34  return tilesWithPreloadedImages;
35}

I kodesnutten over bruker jeg på linje 16 new Set for å sikre at jeg får 8 tilfeldige spillekort, og at hvert kort er unikt. Når jeg har fått 8 unike kort, gjør jeg settet om til et array og bruker deretter på linje 23 spread-operatoren for å å doble antallet kort slik at vi får 16 kort, der to og to er like (vi kunne også brukt array-metoden concat). Så sorterer vi hele arrayet med alle spillekortene i tilfeldig rekkefølge.

Legg ellers merke til at jeg definerer en funksjon preloadImage(), dette er for å få bedre ytelse og mer umiddelbar respons når et bilde skal vises etter å ha klikket et kort. Jeg oppretter et nytt HTML image-element med new Image() og setter src til det bildet som skal vises for hvert spillekort. På den måten kan jeg lagre et forhåndslastet bilde for hvert kort i gameState.tiles og slipper å laste bildet på nytt hver gang spilleren klikker på et kort.

En sjekk i Network-taben i Chrome viser at alle bildene lastes i det et nytt spill startes, og at ingenting lastes på nytt underveis (jeg preloader også alle lydene):

Vi preloader alle bilder for bedre ytelse.
Vi preloader alle bilder for bedre ytelse.

Det som til slutt blir returnert fra shuffleTiles-funksjonen (og lagret i gameState.tiles) er et array med ett objekt for hvert spillekort, som kan se slik ut (totalt 16 kort):

1(16) [{…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}]
20: {name: 'rom-monster', src: './images/21.svg', image: img, isMatched: false}
31: {name: 'gulrot', src: './images/3.svg', image: img, isMatched: false}
42: {name: 'parasoll', src: './images/13.svg', image: img, isMatched: false}
53: {name: 'telefon', src: './images/37.svg', image: img, isMatched: false}
64: {name: 'kuleramme', src: './images/15.svg', image: img, isMatched: false}

Det siste som gjøres i newGame-funksjonen er å tegne opp et tomt spillebrett ved å kalle funksjonen drawEmptyBoard:

1// ./src/main.ts
2
3  function drawEmptyBoard() {
4    for (let i = 0; i < 16; i++) {
5      const tile = document.createElement('div');
6      tile.classList.add('tile');
7      tile.setAttribute('data-tile', `${i}`);
8      tile.addEventListener('click', flipTile);
9
10      if (board instanceof HTMLElement) {
11        board.appendChild(tile);
12      }
13    }
14  }

I kodesnutten over opprettes 16 tomme div-er med klassen .tile, samt en data-attributt med navn data-tile og verdi fra 0 til 15. Denne data-attributten skal vi senere bruke for å identifisere hvilket av de 16 kortene brukeren har klikket. Til slutt legger vi på en klikk-eventlistener på hvert kort (som peker til funksjonen flipTile) og legger kortet til i DOM-en med board.appendChild(tile).

Let the game begin!

Nå når spillebrettet er tegnet opp har vi et rutenett med 4 x 4 ruter, der et klikk på en av rutene kaller funksjonen flipTile().

Spillebrettet ser slik ut. Vi må holde styr på hvilket symbol som skal vises i hver rute uten å lagre dette i DOM-en, for at det ikke skal bli for lett å jukse.
Spillebrettet ser slik ut. Vi må holde styr på hvilket symbol som skal vises i hver rute uten å lagre dette i DOM-en, for at det ikke skal bli for lett å jukse.

I flipTile lagrer vi først DOM-elementet spilleren har klikket på, før vi leser verdien til data-tile:

1// ./src/main.ts
2
3  function flipTile(e: Event) {
4    let clickedDOMElement: HTMLElement;
5    if (e instanceof Event) {
6      clickedDOMElement = <HTMLElement>e.currentTarget;
7    } else {
8      clickedDOMElement = e;
9    }
10
11    const clickedTileID = Number(clickedDOMElement.getAttribute('data-tile'));
12
13// ... resten av koden

Vi må holde styr på om det er første eller andre kort spilleren har klikket på. Er det første kort, skal spilleren få lov til å klikke et kort til. Er de to kortene like, skal kortene beholdes og isMatched settes til true for begge kortene. Er kortene ulike, skal de "snus" tilbake. I gameState har jeg en property som heter firstTileId som er satt til null hvis ingen kort har blitt klikket. Første gang spilleren klikker et kort, settes firstTileId til verdien til dette kortet (0-16). Jeg lagrer også en peker til DOM-elementet slik at jeg kan fjerne symbolet som vises etter at spilleren har klikket begge kort:

1// ./src/main.ts
2
3  if (gameState.firstTileID === null) {
4    gameState.firstTileID = clickedTileID;
5    gameState.firstTileDOMElement = clickedDOMElement;
6  } else {
7    gameState.tries++;
8    if (triesDisplay instanceof HTMLElement) {
9      triesDisplay.innerText = gameState.tries.toString();
10    }
11  }

Vi plasserer så en <img>-tag i ruten spilleren har klikket på:

1// ./src/main.ts
2
3const tileImage = gameState.tiles[clickedTileID].image;
4
5// Increase tiles flipped counter if less than two tiles have been flipped
6if (gameState.tilesFlipped < 2) {
7  gameState.tilesFlipped++;
8
9  if (tileImage instanceof HTMLImageElement) {
10    clickedDOMElement.appendChild(tileImage);
11  }
12}

Hvis spilleren har snudd nøyaktig to kort, kaller vi funksjonen checkForMatch() som returnerer true hvis de to kortene er like. Som argumenter sender vi inn ID-en til første og andre kort spilleren har klikket. checkForMatch-funksjonen sammenligner så name for de to kortene for å finne ut om det er to like kort:

1// ./src/main.ts
2
3function checkForMatch(firstTile: number, secondTile: number): boolean {
4  return gameState.tiles[firstTile].name === gameState.tiles[secondTile].name;
5}
6
7function flipTile(e: Event) {
8  // ...
9  // ... resten av koden
10  // ...
11  
12  if (gameState.tilesFlipped === 2) {
13    if (checkForMatch(gameState.firstTileID, clickedTileID) === true) {
14      gameState.tiles[gameState.firstTileID].isMatched = true;
15      gameState.tiles[clickedTileID].isMatched = true;
16    } else {
17      gameState.isBlocked = true;
18
19      // No match, flip back the two tiles after a short delay
20      setTimeout(() => {
21        if (gameState.firstTileDOMElement) {
22          gameState.firstTileDOMElement.innerHTML = '';
23        }
24        clickedDOMElement.innerHTML = '';
25        gameState.isBlocked = false;
26      }, 500);
27    }
28
29    gameState.tilesFlipped = 0;
30    gameState.firstTileID = null;
31
32    if (hasWon()) {
33      setTimeout(() => {
34        // Show modal. Pass in a callback function for resetting the game
35        modal({
36          title: 'Du klarte det!',
37          body: `Du brukte ${gameState.tries} forsøk. Vi er alle stolte av deg! Men prøv gjerne på nytt. Kanskje du kan klare det på færre forsøk?`,
38          buttonText: 'Prøv igjen',
39          modalBtnCB: newGame,
40        });
41      }, 250); // Pause for a bit after last tile is clicked before showing modal
42    }
43  }
44}

Hvis de to kortene er like (linje 12 i kodesnutten over) setter vi isMatched til true for både første og andre kort som er snudd. Dette for å i starten av flipTile-funksjonen sjekke om kortet allerede er snudd, og isåfall returnere uten å gjøre noe (jeg har ikke tatt med dette i koden over, for å korte ned litt).

Hvis de to kortene ikke er like, fjerner jeg bildene fra begge kortene etter en liten pause (linje 20-26).

Så tilbakestiller vi gameState.tilesFlipped og gameState.firstTileID slik at vi er klare for å sjekke neste par med kort.

Helt til slutt (linje 32 i kodesnutten over) kaller vi funksjonen hasWon() for å sjekke om spilleren har klart å snu og matche alle spillekortene på brettet:

1// ./src/main.ts
2
3function hasWon(): boolean {
4  return gameState.tiles.every((tile) => tile.isMatched);
5}

Her bruker vi enkelt og greit array-metoden every til å sjekke om samtlige 16 ruter på brettet har isMatched satt til true.

Har spilleren klart å matche alle 16 kort, viser vi en modal med en hyggelig melding og antall forsøk, samt en knapp for å prøve på nytt. Et klikk på denne knappen kaller newGame-funksjonen på nytt, som så nullstiller alt som bør nullstilles og tegner opp et nytt, tomt spillebrett på skjermen.

Litt animasjoner og lyd

For å gjøre spillet litt mindre kjedelig har jeg også lagt på litt lydeffekter. Opprinnelig hadde jeg en liten hjelpefunksjon for å spille lyder, men jeg fant ut at det var litt mindre forsinkelse ved å preloade alle lydene på forhånd. Jeg har derfor en fil, gamesounds.ts, der jeg ved oppstart av spillet oppretter et nytt HTML audio-element for hver av lydene:

1// ./lib/gamesounds.ts
2
3export const SOUND_FLIP = new Audio('./audio/flip.mp3');
4export const SOUND_MATCH = new Audio('./audio/match.mp3');
5export const SOUND_WIN = new Audio('./audio/win.mp3');
6export const SOUND_NEWGAME = new Audio('./audio/newtiles.mp3');
7export const SOUND_UHOH = new Audio('./audio/sfx_sound_nagger2.mp3');
8

Det å spille av en lyd kan da gjøres på denne måten:

1void SOUND_WIN.play();

Grunnen til at jeg bruker void er for at TypeScript ikke skal protestere. .play er en asynkron funksjon og da vil TypeScript at jeg enten awaiter den, bruker .then, eller ignorerer den ved å bruke void foran. Det som i praksis da skjer er at .play returnerer undefined i stedet for et promise og TypeScript slutter å bry seg 🙂.

Til slutt ville jeg ha med noen animasjoner for å sprite opp spillet litt. De enkleste animasjonene lagde jeg bare med vanlige CSS-animasjoner, for eksempel en "riste"-animasjon hvis spilleren klikker på et kort som allerede er snudd. Jeg ville også ha en animasjon der alle spillerutene kommer flygende inn på skjermen i det spillet starter. Til dette brukte jeg animasjonsbiblioteket GSAP. Dette er et animasjonsbibliotek med veldig mange avanserte muligheter, men som er forholdsvis enkelt å bruke hvis du ikke skal gjøre verdens mest kompliserte ting. Jeg installerte GSAP med npm install gsap og lagde en egen fil med følgende kode:

1// ./src/animate-tiles.ts
2
3import gsap from 'gsap';
4
5export function animateTiles() {
6  gsap.from('.even', {
7    duration: 0.5,
8    opacity: 0,
9    x: -400,
10    stagger: -0.05,
11    rotate: '-90deg',
12    ease: 'back',
13  });
14  gsap.from('.odd', {
15    duration: 0.5,
16    opacity: 0,
17    x: 400,
18    stagger: -0.05,
19    rotate: '-90deg',
20    ease: 'back',
21  });
22}
23

Dette animerer x-posisjonen til alle kort som har CSS-klassen .even fra -400, og alle med klassen .odd fra +400 (til 0, altså der hvor alle kortene skal befinne seg til slutt). Kortene roteres også samtidig som de beveger seg.

Jeg la til klassene .even og .odd på kortene med denne lille kodesnutten (linje 13-21) i drawEmptyBoard(), der jeg tegner opp det tomme spillebrettet:

1// ./src/main.ts
2
3function drawEmptyBoard() {
4  for (let i = 0; i < 16; i++) {
5    const tile = document.createElement('div');
6    tile.classList.add('tile');
7    tile.ariaLabel = 'Brikke';
8    tile.setAttribute('data-tile', `${i}`);
9    tile.setAttribute('tabindex', '0');
10    tile.addEventListener('click', flipTile);
11
12    // Add classes used for animation of tiles when a new board is drawn
13    if (i < 4) {
14      tile.classList.add('odd');
15    } else if (i < 8) {
16      tile.classList.add('even');
17    } else if (i < 12) {
18      tile.classList.add('odd');
19    } else {
20      tile.classList.add('even');
21    }
22
23    if (board instanceof HTMLElement) {
24      board.appendChild(tile);
25    }
26  }
27}

Så kunne jeg importere animateTiles-funksjonen i main.ts med import { animateTiles } from './lib/animate-tiles' og kalle den med animateTiles() for å få en fin animasjonseffekt når spillet starter.

Jeg ville også ha en subtil animasjon på Teflonhjerne-logoen der hver bokstav beveger seg rolig opp og ned i en bølgeform. Det er en enkel tekstbasert logo laget i Figma basert på Google-fonten Bangers og med en emoji for "eksploderende hode".

For å få til dette la jeg hver bokstav på et eget lag i Figma og ga hvert lag et navn. Så eksporterte jeg til SVG etter å ha krysset av for "Include ID attribute". Dette for å kunne legge på en CSS-animasjon for hver bokstav ved å referere til ID-en (for eksempel #t for bokstaven T i Teflonhjerne).

Hver bokstav i logoen ble et eget navngitt lag i Figma. Disse navnene kan følge med som CSS-ID-er når du eksporterer som SVG.
Hver bokstav i logoen ble et eget navngitt lag i Figma. Disse navnene kan følge med som CSS-ID-er når du eksporterer som SVG.

For å kunne animere hver bokstav kan jeg ikke legge til SVG-logoen med den vanlige img-taggen, men må bruke inline SVG. En SVG-fil er en tekstfil som beskriver vektorbasert grafikk, og du kan kopiere teksten inn i HTML-koden. Jeg la den til i index.html inne i en div som jeg kunne style.

1// ./src/index.html
2
3// ...
4
5<div id="game-heading">
6        <svg
7          width="100%"
8          height="110"
9          viewBox="0 0 355 54"
10          fill="none"
11          xmlns="http://www.w3.org/2000/svg"
12          alt="Teflonhjerne-logo"
13          aria-label="Teflonhjerne-logo"
14        >
15          <g id="teflonhjerne-logo">
16            <path
17              id="t"
18              d="M0.76001 53.064C1.74134 48.3707 2.68001 43.7413 3.57601 39.176C3.96001 37.256 4.34401 35.2507 4.72801 33.16C5.11201 31.0693 5.47468 29.0213 5.81601 27.016C6.20001 24.968 6.52001 23.0053 6.77601 21.128C7.07468 19.208 7.33068 17.4587 7.54401 15.88C6.56268 15.752 5.56001 15.7093 4.53601 15.752C3.55468 15.7947 2.57334 15.6667 1.59201 15.368C1.63468 14.6853 1.65601 13.896 1.65601 13C1.65601 12.104 1.67734 11.208 1.72001 10.312C1.76268 9.33066 1.80534 8.28533 1.84801 7.176C2.95734 7.26133 4.06668 7.32533 5.17601 7.368C6.28534 7.41066 7.39468 7.432 8.50401 7.432C10.68 7.432 12.7707 7.38933 14.776 7.304C16.824 7.176 18.7013 7.048 20.408 6.92C22.1573 6.74933 23.864 6.55733 25.528 6.344L24.824 9.8C24.6107 10.7813 24.3973 11.784 24.184 12.808C24.0133 13.832 23.8427 14.7067 23.672 15.432C22.7333 15.688 21.7733 15.88 20.792 16.008C19.8533 16.0933 18.9147 16.2213 17.976 16.392C17.6773 17.8427 17.3573 19.464 17.016 21.256C16.6747 23.0053 16.3333 24.84 15.992 26.76C15.6933 28.6373 15.3947 30.5573 15.096 32.52C14.7973 34.4827 14.4987 36.36 14.2 38.152C13.5173 42.376 12.8773 46.7493 12.28 51.272L0.76001 53.064Z"
19              fill="black"
20            />
21            
22// ...

Nå kan jeg referere til hver enkelt bokstav i CSS-en. Slik er for eksempel de tre første bokstavene stylet. Legg merke til at det er 0.4 sekunders forsinkelse (animation-delay) mellom hver bokstav for å få til bølgeeffekten:

1// ./style.css
2
3#t {
4  animation: floating 4s ease-in-out infinite;
5  animation-delay: 0s;
6}
7
8#e-3 {
9  animation: floating 4s ease-in-out infinite;
10  animation-delay: 0.4s;
11}
12
13#f {
14  animation: floating 4s ease-in-out infinite;
15  animation-delay: 0.8s;
16}
17
18@keyframes floating {
19  0% {
20    transform: translateY(0);
21  }
22
23  50% {
24    transform: translateY(-4px);
25  }
26}

Jeg la også til en ekstra "riste på hodet"-animasjon på hodet.

Prøv spillet!

For oversiktens skyld har jeg med vilje ikke tatt med all koden i denne artikkelen, men du finner selvfølgelig alt på min Github. Jeg har blant annet lagt til tastaturnavigasjon, og det er litt logikk for å forhindre at brukeren klikker på kort som allerede er snudd.

Hvis du vil prøve spillet, finner du det her: Spill Teflonhjerne

Publisert: 05. august 2022 (oppdatert: 16. oktober 2022)