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:
/game/
: gibt den Zustand des Games im JSON-Format zurück./play/<cellid>/<color>
: Setzt im Game die Zelle <cellid>
auf <color>
. Gibt anschliessend den Game-Zustand zurück.npm init
npm install express cookie-parser
package.json
: „type“: „module“
node-modules
zu .gitignore
hinzu.
Zuerst benötigen wir eine Datei app.js
, die definiert, wie der Server funktioniert, und welche APIs wir bereitstellen:
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}!`) })
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:
// 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!
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?
let grid = Array(9).fill(' ')
.toJson()
, die Code ähnlich wie den folgenden produziert:import express from 'express' const app = express() const port = 3000 let game = { "id": 0, "state": { "progress" : "playing", "next": "X" }, "grid": [ " ", " ", " ", " ", " ", " ", " ", " ", " " ], }; app.get('/game/', (req, res) => { res.json(game); }); // Listen on the given port app.listen(port, () => { console.log(`Example app listening on port ${port}`) })
In app.js
erstellen wir das eine Spiel - in Zukunft wollen wir natürlich für verschiedene Benutzer mehrere Spiele erzeugen, aber für den Moment reicht eines. Nach dem Neustart lässt sich der Spielzustand auf http://localhost:3000/game abrufen.
Die Spieler müssen den Spielzustand auch ändern können, dafür erzeugen wir eine zusätzliche Route:
app.get('/play/:cell/:color', (req, res) => { // TODO: security let cell = req.params['cell']; let color = req.params['color']; game.grid[cell] = color; if (game.state.next == 'X') { game.state.next = 'O'; } else { game.state.next = 'X'; } res.json(game); });
Nun haben wir einen spielbaren Server implementiert. Über URLs wie http://localhost:3000/play/3/X lässt sich der Spielzustand verändern. Kannst du (ganz ohne Grafik, nur mit den obigen URLs) ein Spiel spielen?
Was gibt es noch zu lösen?
play
API aufrufen und anschliessend den Inhalt des HTMLs an den Spielzustand anpassen.X
oder O
ist?Die kanonische Methode, um den Benutzer oder schon nur eine Session zu identifizieren, sind Cookies. Normale Session-Cookies leben nur eine kurze Zeit (während die Webseite offen ist) und sind aus Datenschutzperspektive unproblematisch.
Der cookie-parser
muss dafür importiert und in der App installiert werden.
Der folgende Code weist jeder Session eine eigene zufällige Id (z.B. 25d259e7-fd02-47b4-9377-e722d505717c
) zu. Zudem wird das zurückgegebene JSON so modifiziert, dass der Eintrag color
jeweils X
oder O
ist, je nachdem, welcher Spieler den Request auslöst. Für den Spieler, der an der Reihe ist, wird der Eintrag yourturn
gesetzt. Dafür wird zuerst eine Kopie (structuredCopy
) des Server-Spielzustands erstellt - die color
ist ja je nach Client verschieden.
import express from 'express' import cookieParser from 'cookie-parser' const app = express() app.use(cookieParser()) // ... // Returns the user's userid, creating a random // userid if there is none, storing it as a cookie. function getUserId(req, res) { let userid = req.cookies.userid if (userid == undefined) { userid = crypto.randomUUID() // random unique ID res.cookie('userid', userid) } return userid } // Create a copy of game to return and // sets the 'color' and 'yourturn' entries // according to the user id. function sendGame(req, res) { // Join a game that is waiting for players. let userid = getUserId(req, res) if (game.player_X == undefined) { // Join game as X console.log(`User ${userid} joining game as X`) game.player_X = userid } else if (game.player_O == undefined && game.player_X != userid) { // Join game as O console.log(`User ${userid} joining game as O`) game.player_O = userid } // Create a copy and set the 'color' property let copy = structuredClone(game) if (game.player_X == userid) { // User is already assigned X copy.color = 'X' } else if (game.player_O == userid) { // User is already assigned O copy.color = 'O' } else { // game already has two other players } // Set the 'yourturn' property if applicable if (copy.state.next == copy.color) { copy.yourturn = true } res.json(copy) } app.get('/game/', (req, res) => { sendGame(req, res); });
Natürlich könnte es sein, dass ein falsch-programmierter Client einen ungültigen Spielzug versucht - dies sollte abgefangen und mit einem 403
(Forbidden) HTTP-Status-Code beantwortet werden:
app.get('/play/:cell/:color', (req, res) => { // TODO: security let cell = req.params['cell']; let color = req.params['color']; if (color != game.state.next) { res.status(403).send("Not your turn, my friend!"); return; } if (game.grid[cell] != " ") { res.status(403).send("Cell is not free, buddy!"); return; } if (game['player_' + color] != getUserId(req, res)) { res.status(403).send("Trying to play for someone else?!"); return; } game.grid[cell] = color; if (game.state.next == 'X') { game.state.next = 'O'; } else { game.state.next = 'X'; } sendGame(req, res); });
Neu muss die Spiellogik nicht mehr auf dem Client passieren. Vielmehr soll jeder Knopfdruck das play
API aufrufen und anschliessend den Inhalt des HTML an den Spielzustand anpassen.
Zudem sollten wir den Zustand des Spiels auch laden können, ohne einen Spielzug zu machen - das gelingt über das game
API. Als erste Version reicht es, den Zustand auf Knopfdruck zu laden. Herausforderung: Der Spielzustand wird periodisch (z.B. jede Sekunde) neu geladen, solange der Gegner am Zug ist.
Der Click-Handler jedes Buttons soll das play
API aufrufen und anschliessend den Inhalt des HTMLs an den Spielzustand anpassen.
// Make a play async function play(cell) { response = await fetch(`/play/${cell}/${game.color}`); await updateGame(response) poll(); } // Set up click handlers and start polling. async function init() { poll(); let i = 0; for (const button of document.querySelectorAll(".grid button")) { const cell = i; button.addEventListener('click', () => play(cell)); i++; } } init()
Bleibt nur noch, das HTML an das geänderte Game anzupassen:
let game = { "state": { "progress" : "waiting", }, "grid": [ " ", " ", " ", " ", " ", " ", " ", " ", " " ], }; /** * Updates the game state from the JSON response received from a remote HTTP endpoint. * * @param {Response} response the HTTP response from a remote JSON endpoint. */ async function updateGame(response) { if (!response.ok) { let error = await response.text(); console.log("Error: " + error); return; } game = await response.json(); updateHtml(game); } // Updates the HTML to match the game state given as JSON. function updateHtml(json) { let i = 0; for (const button of document.querySelectorAll(".grid 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++; } }