===== 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: * Die Spiel-Logik muss vom Browser auf den Server wandern. * Dazu muss ein Server-Prozess gestartet werden, wir verwenden dafür [[https://nodejs.org/|nodejs]] und [[https://expressjs.com/|express]]. * Der Server muss geeignete Web-APIs zur Verfügung stellen: * ''/game/'': gibt den Zustand des Games im JSON-Format zurück. * ''/play//'': Setzt im Game die Zelle '''' auf ''''. Gibt anschliessend den Game-Zustand zurück. * Das Javascript im Browser muss sich nur noch darum kümmern, die richtigen Web-APIs auf dem Server aufzurufen, und den Zustand des Spiels im HTML darzustellen. ==== Installation von node.js und express ==== * Node.js installieren: * https://nodejs.org/en/download/prebuilt-installer * Projekt initialisieren: * Eine Eingabeaufforderung **im Ordner der Webapp** öffnen. * ''npm init'' * ''npm install express cookie-parser'' * Öffne die Datei ''package.json'': * füge einen Eintrag hinzu: '' "type": "module"'' * Falls du git verwendest: * füge ''node-modules'' zu ''.gitignore'' hinzu. ==== App definieren ==== 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}!`) }) ==== 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: // 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? * Der Spielzustand kann nicht mehr im HTML gespeichert werden. Es bietet sich an, ein Array von Buchstaben zu verwenden ''let grid = Array(9).fill(' ')''. * Der Spielzustand muss als JSON vom Server zu den Clients gesendet werden. Schreibe eine Funktion ''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? * Der Browser benötigt auch Javascript. Der Click-Handler jedes Buttons soll das ''play'' API aufrufen und anschliessend den Inhalt des HTMLs an den Spielzustand anpassen. * Wie stellen wir sicher, dass der Spielzug überhaupt vom richtigen Spieler her kommt? * Wie weiss der Client (Browser), ob er ''X'' oder ''O'' ist? * Gewinner-Erkennung? * Wie können mehrere Spiele betreut werden? === User Identifizierung === 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); }); === Validation === 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); }); ==== Client-Side Javascript ==== 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. ++++Idee:| * Solange der Spielzustand ändern könnte, laden wir den ''/game'' Endpunkt jede Sekunde neu. * Dazu benützen wir eine ''sleep()'' Funktion. // Polls the server state until it's our turn. async function poll() { // Keep polling the server while the game state could change remotely. while (game.state.progress == "playing" && !game.yourturn) { await sleep(1000); response = await fetch('/game'); await updateGame(response) } } // Returns a promise that waits ms milliseconds. function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } ++++ 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++; } } ==== Hinweise ==== * Die ganze Web-App mit JS & Node: https://github.com/tkilla77/ksr_tictactoe/tree/simple_app * Alternative mit Python & Flask auf der Serverseite: https://github.com/tkilla77/ksr_tictactoe/tree/security