Inhaltsverzeichnis

TicTacToe mit Server

Mit unserem Wissen über Web-APIs und JSON wollen wir nun die TicTacToe Web-App weiterentwickeln, so dass zwei Spieler:innen übers Web gegeneinander spielen können.

Dazu muss Folgendes geschehen:

Installation von node.js und express

App definieren

Zuerst benötigen wir eine Datei app.js, die definiert, wie der Server funktioniert, und welche APIs wir bereitstellen:

app.js
import express from 'express'
 
const app = express()
const port = 3000
 
// Hello World.
app.get('/hello', (req, res) => {
  res.send('Hello World!')
})
 
// Listen on the given port
app.listen(port, () => {
    console.log(`Example app listening on port ${port}`)
})

Mit dem Kommandozeilenaufruf node app.js wird der Node-Server gestartet. Auf der Adresse http://localhost:3000/hello können wir auf die oben definierte API zugreifen.

Der Code bedeutet: Wann immer eine Anfrage für den Pfad /hello ankommt, so sende als Response (res) den String Hello World zurück.

Es ist auch möglich, Teile der im Request (req) verlangten Adresse als Parameter zu erhalten:

app.get('/hello/:name', (req, res) => {
    let name = req.params['name']
    res.send(`Hello ${name}!``)
})

Statische Dateien

Die HTML und CSS Dateien bleiben unverändert - der Server soll diese bitte direkt zum Browser übertragen. Das erreichen wir, indem wir einen Ordner static anlegen, und die Dateien dorthin verschieben. Node wird instruiert, diese Dateien unverändert auszuliefern:

app.js
// Serve all files from static/ as is.
// For example, a request for '/tictactoe.html' will be served from
// 'static/tictactoe.html'
app.use(express.static('static'))

Ausprobieren: Node neu starten und den URL http://localhost:3000/tictactoe.html laden - unser HTML + CSS sollte korrekt angezeigt werden!

Server-Side TicTacToe

Erstelle eine Kopie deiner tictactoe.js Datei, um auf dem Server ein Spiel laufen zu lassen. Idealerweise ist deine Datei bereits objektorientiert, damit der Server eine Vielzahl von Spielen parallell betreuen kann.

Was muss sich ändern?

{
  id: 0,           // game id
  state: "ended",  // "waiting", "playing", "ended"
  grid: [          // the nine game grid cells
    "X",
    " ",
    " ",
    "X",
    "O",
    " ",
    "X",
    "O",
    " "
  ],
  winner: "X",     // the player who won, if there is one.
  player: "X",     // the user's color
  next: "O"        // the color of the player next in turn
}

In app.js erstellen wir ein Spiel, z.B. mit let game = TicTacToe(). Damit das funktioniert, müssen wir alle Funktionen oder Klassen in tictactoe.js mit export markieren und in app.js importieren:

tictactoe.js
export class TicTacToe {
  ...
}
app.js
import {TicTacToe} from './tictactoe.js'
 
let the_game = new TicTacToe();
 
app.get('/game/:gameid', (req, res) => {
    return the_game.toJson();
})
 
app.get('/set/:gameid/:cell', (req, res) => {
    the_game.set(req.params['cell'])
    res.json(the_game.toJson())
})

Nun sollte unser Server ein einziges Game betreiben. Der Zustand sollte unter http://localhost:3000/game/0 zurückgeliefert werden. Ein Spielzug wird über das set API gemacht, z.B. sollte das mittlere Feld mit http://localhost:3000/set/0/4 gesetzt werden können. Kannst du (ganz ohne Grafik, nur mit den obigen URLs) ein Spiel spielen?

Was gibt es noch zu lösen?

Client-Side Javascript

Der Browser benötigt auch Javascript. Der Click-Handler jedes Buttons soll das set API aufrufen und anschliessend den Inhalt des HTMLs an den Spielzustand anpassen.

tictactoe_client.js
class Tictactoe_Client {
   game_id = 0;  // TODO set when joining game
   /**
     * Fetches the game state, updates the UI, and installs click handlers.
     */
    async init() {
        let i = 0;
        for (const button of this.view.grid.getElementsByTagName("button")) {
            const cell = i;
            button.addEventListener('click', () => {
                this.handleJsonUrl(`/set/${this.game_id}/${cell}`);
            });
            i++;
        }
    }
 
    /**
     * Fetches the given URL and updates the internal state from the parsed JSON.
     * 
     * Keeps polling for updates if the updated state could change remotely.
     * 
     * @param {string} url the URL to fetch that will return tictactoe JSON.
     */
    async handleJsonUrl(url) {
        let response = await fetch(url);
        if (!response.ok) {
            let error =  await response.text();
            console.log("Error: " + error);
            return;
        }
        const json = await response.json();
        this.updateHtml(json);
    }
 
    /** Update the HTML based on JSON game state. */
    updateHtml(json) {
        let i = 0;  
 
        for (const button of this.grid.getElementsByTagName("button")) {
            const cellText = json.grid[i];
            // Set the data-state attribute which drives CSS formatting.
            button.setAttribute('data-state', cellText);
            // Set the text contents of the cell.
            button.textContent = cellText;
            i++;
        }
    }
}

Sicherheit, User Ids & Cookies

Um sicherzustellen, dass jede:r Benutzer:in nur in einem Spiel ist, und kein böswilliger Akteur einen falschen Spielzug ausführen kann, wollen wir eine Benutzer-Id für jede Verbindung erstellen. Dafür nützen wir Cookies. Das Cookie wird gesetzt wenn zum ersten Mal einem Spiel beigetreten wird:

app.js
import express from 'express'
import cookieParser from 'cookie-parser'
import {TicTacToe, GameState} from './tictactoe.js'
 
const app = express()
app.use(cookieParser())
const port = 3000
 
function getUserId(req, res) {
    let userid = req.cookies.userid
    if (userid == undefined) {
        userid = crypto.randomUUID()
        res.cookie('userid', userid)
    }
    return userid
}
 
app.get('/join', (req, res) => {
    const userid = getUserId(req, res)
    for (let game of Object.values(games)) {
        if (game.isWaiting()) {
            game.join(userid)
            res.json(game.toJson(userid))
            return
        }
    }
    // No waiting game found - create a new one
    let game = new TicTacToe(nextGameId)
    games[nextGameId] = game
    nextGameId += 1
 
    game.join(userid)
    res.json(game.toJson(userid))
})

Hinweise