Kirjautuminen

Haku

Tehtävät

Koodit: JavaScript, PHP: Noppapeli, tuloslistan teko

Kirjoittaja: Metabolix

Kirjoitettu: 14.04.2020 – 14.04.2020

Tagit: ohjelmointitavat, verkko, koodi näytille, vinkki, web

Tässä koodissa on pieni selainpeli, jossa heitetään noppaa ja yritetään saada peräkkäisistä heitoista mahdollisimman suuri keskiarvo. Itse peli on yksinkertainen, eli käytännössä koodi esittelee JavaScript- ja PHP-tekniikoita, joilla saadaan tietoa siirrettyä ja tuloslista tehtyä.

Aiheita: async/await, Fetch API, JSON, SQLite, PCRE.

JavaScript-koodissa käytetään asynkronisia funktioita, jolloin await-sanan avulla voi hoitaa myös hitaita asioita (kuten verkkoliikennettä) helposti ja yksinkertaisesti.

JavaScript-koodi hakee tulokset palvelimelta fetch-rajapinnalla, ja palvelin vastaa JSON-muodossa. Tällä tavalla tietoa saa haettua palvelimelta ihan parin rivin koodilla.

JavaScript-koodi tallentaa tulokset palvelimelle fetch-rajapinnalla, ja koodissa on esimerkit sekä JSON-muotoisesta lähetyksestä että perinteisestä lomakemuotoisesta lähetyksestä. Kummatkin tavat ovat paljon kätevämpiä kuin piilotettujen lomakkeiden rakentelu.

JavaScript-koodissa myös muotoillaan aikoja ja lukuja suomeksi toLocaleString-metodilla.

PHP-koodi käsittelee tuloksia SQLite-tietokannan avulla. Tietokanta voittaa pelkät tiedostot monesta syystä: Jos tietoja luetaan ja kirjoitetaan samaan aikaan, tietokanta varmistaa ehjän tuloksen lukijalle. Jos tietoja kirjoittaa useampi käyttäjä samaan aikaan, tietokanta hoitaa lukitukset niin, että tieto pysyy ehjänä ja molempien käyttäjien muutokset toteutuvat. Näiden asioiden hoitaminen itse tiedostoilla vaatii ylimääräistä vaivaa ja kikkailua esimerkiksi flock-kutsuilla tai muilla vastaavilla.

PHP-koodissa poistetaan nimimerkistä häiriötekijöitä kuten näkymättömiä merkkejä. Tähän käytetään säännöllisiä lausekkeita (PCRE) eli funktiota preg_replace.

PHP-koodi muotoilee vastauksen JSON-muotoon ja asettaa tätä varten järkevät HTTP-otsikkotiedot: lähetettävän tiedon tyypin (Content-Type: application/json) ja välimuistin käyttökiellon (Cache-Control: no-store).

<?php

// Tuloslistan koko, ja arkistoidaanko silti myös vanhat tulokset.
$tuloksia = 10;
$arkisto = true;

// Tuloslistan haku tunnistetaan GET-parametrista.
$haku = !empty($_GET["tuloslista"]);

// Tuloksen lähetys tunnistetaan POST-parametreista.
$lähetys = !empty($_POST["tulos"]);

// JSON-muotoinen lähetys tunnistetaan tietotyypistä.
$lähetys_json = ($_SERVER["CONTENT_TYPE"] ?? "") == "application/json";

// Jos on pyydetty tulosten käsittelyä, tehdään tarvittavat temput.
if ($haku || $lähetys || $lähetys_json) {
	// Vastaus annetaan JSON-muodossa, ja välimuistia ei saa käyttää.
	header("Content-Type: application/json");
	header("Cache-Control: no-store");

	// Tulokset pidetään SQLite-tietokannassa.
	$tietokanta = new PDO("sqlite:" . __DIR__ . "/tulokset.sqlite");

	// Luodaan tietokanta, jos sitä ei vielä ole.
	$tietokanta->exec("CREATE TABLE tulokset (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		pisteet REAL,
		nimimerkki TEXT,
		aika INTEGER
	)");

	// Funktio parhaiden tulosten hakemiseen.
	function hae_parhaat_tulokset($tietokanta, $tuloksia) {
		$tulokset = $tietokanta->query("SELECT * FROM tulokset ORDER BY pisteet DESC, aika ASC LIMIT {$tuloksia}")->fetchAll(PDO::FETCH_ASSOC);
		// Tietokanta palauttaa kaiken tekstinä, joten korjataan luvut luvuiksi.
		foreach ($tulokset as &$rivi) {
			$rivi["pisteet"] = +$rivi["pisteet"];
			$rivi["aika"] = +$rivi["aika"];
		}
		return $tulokset;
	}

	// Haetaan parhaat tulokset.
	$tulokset = hae_parhaat_tulokset($tietokanta, $tuloksia);

	// Jos kyseessä on vain haku, palautetaan tulokset.
	if ($haku) {
		exit(json_encode($tulokset));
	}

	if ($lähetys_json) {
		// Otetaan JSON-tietona lähetetyt tulokset.
		$tulos = @json_decode(file_get_contents("php://input"), true);
	} else {
		// Otetaan lomakemuotoisina lähetetyt tulokset.
		$tulos = $_POST["tulos"];
	}

	// Puretaan tuloksista oikeat arvot, tarkastetaan tietotyypit jne.
	$summa = intval($tulos["summa"] ?? 0);
	$heittoja = intval($tulos["heittoja"] ?? 0);
	$pisteet = $summa && $heittoja ? round($summa / $heittoja, 2) : 0;
	$nimimerkki = strval($tulos["nimimerkki"] ?? "***");

	// Katsotaan, riittääkö pistemäärä listalle.
	if ($pisteet <= ($tulokset[$tuloksia - 1]["pisteet"] ?? 0)) {
		exit(json_encode(["listalla" => false, "tulokset" => $tulokset]));
	}

	// Poistetaan nimimerkistä pelleilyt ja varmistetaan oikea pituus.
	$nimimerkki = preg_replace("/^[^[:graph:]]+|[^[:print:]]|[^[:graph:]]+$/u", "", $nimimerkki);
	$nimimerkki = preg_replace("/^(.{1,16}).*/u", "$1", $nimimerkki) ?: "***";

	// HUOMIO! Käyttäjä voi lähettää mitä tahansa, joten tässä pitäisi
	// jotenkin tarkastaa, että tulos on saavutettu ilman huijausta.
	// Tämä esimerkki luottaa huijarin ystävällisyyteen:
	if (strtolower($nimimerkki) == "huijari" || $pisteet < 1 || $pisteet >= 20) {
		exit(json_encode(["huijaus" => true]));
	}

	// Lisätään listalle oma tulos ja haetaan lista uudestaan.
	$tietokanta->prepare("INSERT INTO tulokset (pisteet, nimimerkki, aika) VALUES (?, ?, ?)")->execute([$pisteet, $nimimerkki, time()]);
	$tulokset = hae_parhaat_tulokset($tietokanta, $tuloksia);

	// Jos arkistointi ei ole käytössä, poistetaan listalta pudonneet.
	if (!$arkisto) {
		$minimi = min(array_column($tulokset, "pisteet"));
		$tietokanta->exec("DELETE FROM tulokset WHERE pisteet < {$minimi}");
	}

	exit(json_encode(["listalla" => true, "tulokset" => $tulokset]));
}

// Jos pyyntö ei koske tuloksia, näytetään itse pelisivu.
header("Content-Type: text/html; charset=UTF-8");
?>
<!DOCTYPE html>
<html lang="fi">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Noppapeli</title>

<h1>Noppapeli</h1>

<p>Tässä pelissä heitetään 20-sivuista noppaa. Milloin tahansa saa lopettaa, ja keskiarvo lähetetään tuloslistalle.</p>

<h2>Peli</h2>

<p>
	Heittoja <span id="heittoja">0</span>,
	viimeisin <span id="viimeisin"></span>,
	keskiarvo <span id="keskiarvo"></span>.
</p>

<p>
	<button id="heitä">Heitä!</button>
	<button id="lopeta">Lopeta</button>
</p>

<p id="tilanne">Aloita peli heittämällä noppaa.</p>

<h2>Tulokset</h2>
<pre id="tulokset"></pre>

<script>
window.addEventListener("load", e => {
	// Funktiot lukujen ja aikaleimojen muotoiluun.
	const locale = "fi";
	function float2str(x) {
		return x.toLocaleString(locale, {minimumFractionDigits: 2, maximumFractionDigits: 2});
	}
	function unixtime2str(x) {
		return new Date(1000 * x).toLocaleString(locale);
	}

	// Pelin tilanne ja tuloslista.
	let peli = null, tulokset = [];

	async function haeTulokset() {
		// Tulokset haetaan fetch-funktiolla ja puretaan JSON-muodosta.
		let vastaus = await fetch("?tuloslista=1");
		tulokset = await vastaus.json();
	}

	async function lopetaPeli() {
		// Pelin lopussa tarkastetaan tulos ja nollataan pelitilanne.
		if (peli) {
			tarkastaTulos(peli);
			peli = null;
		}
	}

	async function tarkastaTulos(peli) {
		// Ensin haetaan tulokset.
		await haeTulokset();

		// Jos ei pääse listalle, ilmoitetaan siitä.
		let pisteet = peli.summa / peli.heittoja;
		if (tulokset.length == 10 && tulokset[9].pisteet >= pisteet) {
			document.getElementById("tilanne").textContent = "Oi voi, et päässyt listalle!";
		} else {
			// Pyydetään nimi listalle.
			let nimimerkki = prompt("Anna nimesi listalle! (Enintään 16 merkkiä.)");
			if (!nimimerkki || nimimerkki.length > 16) {
				nimimerkki = "Ähäkutti";
			}

			/*
			// A: Lähetetään tiedot fetch-funktiolla lomakkeen tapaan.
			let tiedot = new FormData();
			tiedot.append("tulos[nimimerkki]", nimimerkki);
			tiedot.append("tulos[summa]", peli.summa);
			tiedot.append("tulos[heittoja]", peli.heittoja);
			let vastaus = await fetch("?", { method: "POST", body: tiedot });
			*/

			// B: Lähetetään tiedot fetch-funktiolla JSON-muodossa.
			let tiedot = {nimimerkki, summa: peli.summa, heittoja: peli.heittoja};
			let vastaus = await fetch("?", {
				method: "POST",
				headers: {"Content-Type": "application/json"},
				body: JSON.stringify(tiedot)
			});

			// Puretaan vastaus JSON-muodosta ja näytetään tilanne.
			let data = await vastaus.json();
			if (data.huijaus) {
				document.getElementById("tilanne").textContent = "Olet huijari, aika törkeää!";
			} else if (data.listalla) {
				document.getElementById("tilanne").textContent = "Onneksi olkoon, olet taitava nopanheittäjä!";
			} else {
				document.getElementById("tilanne").textContent = "Hups, et päässyt sittenkään listalle!";
			}

			// Jos vastaus sisälsi tulokset, otetaan ne talteen.
			if (data.tulokset) {
				tulokset = data.tulokset;
			}
		}

		näytäTulokset();
		peli = null;
	}

	async function näytäTulokset() {
		// Tulokset kootaan tekstimuodossa, sijasta pidetään kirjaa.
		let teksti = "", sija = 0, sijapisteet = null;
		for (let i = 0; i < tulokset.length; ++i) {
			let rivi = tulokset[i];
			let aika = unixtime2str(rivi.aika);
			let pisteet = float2str(rivi.pisteet);
			if (rivi.pisteet !== sijapisteet) {
				sija = "" + (i + 1);
				sijapisteet = rivi.pisteet;
			}
			teksti += `${sija.padStart(2)}: ${rivi.nimimerkki.padEnd(16)}\t${pisteet.padStart(5)} p\t\t${aika}\n`;
		}
		document.getElementById("tulokset").textContent = teksti;
	}

	async function heitäNoppaa() {
		// Jos peli ei ole käynnissä, aloitetaan uusi.
		if (!peli) {
			peli = {heittoja: 0, viimeisin: 0, summa: 0};
		}

		// Ensimmäinen heitto on aina enintään 12, jotta peli on jännempi.
		peli.viimeisin = 1 + Math.random() * (peli.heittoja ? 20 : 12) | 0;
		peli.summa += peli.viimeisin;
		peli.heittoja += 1;

		// Näytetään tilanne.
		document.getElementById("tilanne").textContent = "Peli on kesken...";
		document.getElementById("heittoja").textContent = peli.heittoja;
		document.getElementById("viimeisin").textContent = peli.viimeisin;
		document.getElementById("keskiarvo").textContent = float2str(peli.summa / peli.heittoja);
	}

	// Sivun avaus: haetaan tulokset, kiinnitetään toiminnot painikkeisiin.
	haeTulokset().then(näytäTulokset);
	document.getElementById("heitä").addEventListener("click", e => heitäNoppaa());
	document.getElementById("lopeta").addEventListener("click", e => lopetaPeli());
});
</script>

Kommentit

AkeMake [10.09.2020 22:29:14]

#

Mielenkiintoinen koodivinkki, vaikkei minulla tuolle käyttöä kyllä olekaan.
Tätä läpi käydessä vastaan tuli merkintä ?: rivillä

$nimimerkki = preg_replace("/^(.{1,16}).*/u", "$1", $nimimerkki) ?: "***";

En ole tuollaiseen merkintään ennen törmännytkään eikä PHP-opaskaan asiasta mainitse.
Mitä tuo siis tekee? Tekeekö se saman kuin ?? merkintä?

Metabolix [10.09.2020 22:44:25]

#

AkeMake kirjoitti:

Tätä läpi käydessä vastaan tuli merkintä ?: ...

Lauseke a ?: b on sama kuin a ? a : b, sillä erolla, että a-lauseketta ei tarvitse laskea (ja kirjoittaa) kuin kerran. Eli jos alkupuoli on "tosi" (eli tässä tapauksessa jos korvauksen tulos on muu kuin tyhjä merkkijono), palautetaan se, ja muuten palautetaan jälkimmäinen lauseke.

Kirjoita kommentti

Muista lukea kirjoitusohjeet.
Tietoa sivustosta