feat: add game system
All checks were successful
deploy to cloudflare pages / deploy (push) Successful in 37s

This commit is contained in:
uku 2024-10-28 12:37:50 +01:00
parent 524316d864
commit 14e38cb573
Signed by: uku
SSH key fingerprint: SHA256:4P0aN6M8ajKukNi6aPOaX0LacanGYtlfjmN+m/sHY/o
7 changed files with 175 additions and 38 deletions

View file

@ -1,5 +1,5 @@
import { error } from "@sveltejs/kit";
import type { GameData, GameOptions } from "./types";
import type { CheckResponse, ClientGameData, GameOptions, ServerGameData } from "./types";
type FetchType = typeof fetch;
@ -15,28 +15,57 @@ let lazyTram: [GeoJSON.Feature, string][] | null = null;
let lazyStops: GeoJSON.Feature[] | null = null;
export async function createGame(
stop: GeoJSON.Feature,
mode: string,
stops: GeoJSON.Feature[],
kv: KVNamespace<string>,
): Promise<GameData> {
): Promise<ClientGameData> {
const uuid = crypto.randomUUID();
const stopNames = stops.map((s) => s.properties!.nom);
await kv.put(`game:${uuid}`, JSON.stringify(stop), { expirationTtl: 600 });
const serverData: ServerGameData = {
mode,
stops,
distances: new Array(stops.length).fill(-1),
scores: new Array(stops.length).fill(-1),
};
await kv.put(`game:${uuid}`, JSON.stringify(serverData), { expirationTtl: 600 });
return {
gameId: uuid,
stopName: stop.properties!.nom,
stopNames,
};
}
export async function stopGame(
export async function saveScore(
uuid: string,
index: number,
distanceCalc: (latlng: [number, number]) => number,
scoreCalc: (dist: number) => number,
kv: KVNamespace<string>,
): Promise<GeoJSON.Feature | null> {
const stop: GeoJSON.Feature | null = await kv.get(`game:${uuid}`, "json");
): Promise<CheckResponse | null> {
const serverData: ServerGameData | null = await kv.get(`game:${uuid}`, "json");
if (!serverData) return null;
if (stop) await kv.delete(`game:${uuid}`);
if (serverData.scores[index] != -1) {
return null;
} else {
const coords = (serverData.stops[index].geometry as GeoJSON.Point).coordinates;
const latlng: [number, number] = [coords[1], coords[0]];
return stop;
const distance = Math.round(distanceCalc(latlng));
const score = Math.round(scoreCalc(distance));
serverData.distances[index] = distance;
serverData.scores[index] = score;
await kv.put(`game:${uuid}`, JSON.stringify(serverData), { expirationTtl: 600 });
return {
distance,
score,
solution: latlng,
};
}
}
export async function getMetro(fetch: FetchType): Promise<[GeoJSON.Feature, string][]> {
@ -127,3 +156,15 @@ export function parseOptions(url: URL): GameOptions {
tram: url.searchParams.has("tram"),
};
}
// https://underscorejs.org/docs/modules/sample.html
export function sample<T>(arr: T[], n: number): T[] {
const last = arr.length - 1;
for (let index = 0; index < n; index++) {
const rand = index + Math.floor(Math.random() * (last - index + 1));
const temp = arr[index];
arr[index] = arr[rand];
arr[rand] = temp;
}
return arr.slice(0, n);
}

View file

@ -9,13 +9,21 @@ export interface MapData {
stops: [number, number][];
}
export interface GameData {
stopName: string;
export interface ServerGameData {
mode: string;
stops: GeoJSON.Feature[];
distances: number[];
scores: number[];
}
export interface ClientGameData {
stopNames: string[];
gameId: string;
}
export interface CheckData {
gameId: string;
index: number;
latlng: [number, number];
}

View file

@ -1,25 +1,24 @@
import { error } from "@sveltejs/kit";
import { stopGame } from "$lib";
import { saveScore } from "$lib";
import type { RequestHandler } from "./$types";
import type { CheckData, CheckResponse } from "$lib/types";
import type { CheckData } from "$lib/types";
export const POST: RequestHandler = async ({ request, platform }) => {
const kv = platform?.env?.TCL_GUESSR_KV;
if (!kv) error(500, "could not connect to kv");
const data: CheckData = await request.json();
const stop = await stopGame(data.gameId, kv);
if (stop) {
// GeoJSON data is LonLat, not LatLon
const coords = stop.geometry.type === "Point" ? stop.geometry.coordinates : [0, 0];
const latlng: [number, number] = [coords[1], coords[0]];
const solution = await saveScore(
data.gameId,
data.index,
(solution) => calcDistance(data.latlng, solution),
(distance) => calculatePoints(distance),
kv,
);
const distance = calcDistance(data.latlng, latlng);
const score = calculatePoints(distance);
const res: CheckResponse = { solution: latlng, distance, score };
return Response.json(res);
if (solution) {
return Response.json(solution);
} else {
return new Response(null, { status: 404 });
}

View file

@ -1,6 +1,6 @@
import { error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { createGame, getMergedStops, getMetro, getTram, parseOptions } from "$lib";
import { createGame, getMergedStops, getMetro, getTram, parseOptions, sample } from "$lib";
export const GET: RequestHandler = async ({ fetch, url, platform }) => {
const kv = platform?.env?.TCL_GUESSR_KV;
@ -15,13 +15,12 @@ export const GET: RequestHandler = async ({ fetch, url, platform }) => {
const lineCodes = new Set<string>(lineColors.map(([f]) => f.properties!.code_ligne));
const crossingStops = await getMergedStops(fetch, lineCodes);
const randomStop = crossingStops[Math.floor(Math.random() * crossingStops.length)];
if (!randomStop) {
const randomStops = sample(crossingStops, 5);
if (!randomStops) {
error(400, "could not select random stop");
}
const gameData = await createGame(randomStop, kv);
const gameData = await createGame(options.mode, randomStops, kv);
return Response.json(gameData);
};

View file

@ -1,19 +1,22 @@
<script lang="ts">
import type { CheckData, CheckResponse, GameData, MapData } from "$lib/types";
import type { CheckData, CheckResponse, ClientGameData, MapData } from "$lib/types";
import { page } from "$app/stores";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
import "leaflet-defaulticon-compatibility";
import { goto } from "$app/navigation";
const zoom = 13;
const center = L.latLng(45.742858495, 4.86163814);
const center: [number, number] = [45.742858495, 4.86163814];
let mapPromise = $state(fetchMap());
let gamePromise = $state(fetchGame());
let results: CheckResponse | null = $state(null);
let currentIndex = $state(0);
let map: L.Map | null = $state(null);
let playerMarker: L.Marker | null = $state(null);
let solutionMarker: L.Marker | null = $state(null);
@ -23,7 +26,7 @@
return fetch("/api/map?" + $page.url.searchParams).then((r) => r.json());
}
async function fetchGame(): Promise<GameData> {
async function fetchGame(): Promise<ClientGameData> {
return fetch("/api/game?" + $page.url.searchParams).then((r) => r.json());
}
@ -70,6 +73,7 @@
const checkData: CheckData = {
gameId: gameData.gameId,
index: currentIndex,
latlng: [playerMarker.getLatLng().lat, playerMarker.getLatLng().lng],
};
@ -89,14 +93,18 @@
map.flyToBounds(solutionLine.getBounds(), { duration: 1 });
solutionMarker = L.marker(res.solution).bindPopup(gameData.stopName).addTo(map).openPopup();
solutionMarker = L.marker(res.solution)
.bindPopup(gameData.stopNames[currentIndex])
.addTo(map)
.openPopup();
} else {
alert("it seems than an error occurred");
}
}
function restartGame() {
async function restartGame() {
if (!map) return;
const gameData = await gamePromise;
playerMarker?.removeFrom(map);
playerMarker = null;
@ -110,7 +118,11 @@
map.setView(center, zoom);
results = null;
gamePromise = fetchGame();
if (gameData.stopNames.length <= currentIndex + 1) {
goto("/results?" + new URLSearchParams({ gameId: gameData.gameId }));
} else {
currentIndex++;
}
}
</script>
@ -120,19 +132,19 @@
<div><button disabled>Loading...</button></div>
{:then [, gameData]}
<h1>{gameData.stopName}</h1>
<h1>{gameData.stopNames[currentIndex]}</h1>
<div>
{#if results === null}
<button onclick={checkLocation} disabled={!playerMarker}>SUBMIT</button>
{:else}
<button onclick={restartGame}>RESTART</button>
<button onclick={restartGame}>NEXT</button>
{/if}
</div>
{/await}
<span class="results" hidden={!results}>
{Math.floor(results?.score ?? 0)} points! Vous etiez à {Math.floor(results?.distance ?? 0)}m.
{results?.score} points! Vous etiez à {results?.distance}m.
</span>
<div id="map"></div>

View file

@ -0,0 +1,16 @@
import type { PageServerLoad } from "./$types";
import { error } from "@sveltejs/kit";
import type { ServerGameData } from "$lib/types";
export const load: PageServerLoad = async ({ url, platform }) => {
const kv = platform?.env?.TCL_GUESSR_KV;
if (!kv) error(500, "could not connect to kv");
const gameId = url.searchParams.get("gameId");
if (!gameId) error(400, "gameId was not specified");
const gameData: ServerGameData | null = await kv.get(`game:${gameId}`, "json");
if (!gameData) error(404, "could not fetch game data");
return { gameData, gameId };
};

View file

@ -0,0 +1,62 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { PageData } from "./$types";
interface Props {
data: PageData;
}
const props: Props = $props();
const gameData = props.data.gameData;
const totalScore = gameData.scores.reduce((a, b) => a + b);
</script>
<div class="container">
<h1>yippee !!!</h1>
<h2>score total: {totalScore}</h2>
<span>mode: {gameData.mode}</span>
<table>
<tbody>
{#each gameData.stops as stop, i}
<tr>
<td>{stop.properties!.nom}</td>
<td>{gameData.scores[i]} points</td>
<td>{gameData.distances[i]} metres</td>
</tr>
{/each}
</tbody>
</table>
<span class="small">id de partie: {props.data.gameId}</span>
<button class="restart" onclick={() => goto("/")}>RELANCER !!!</button>
</div>
<style>
.container {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
}
td {
border: 1px solid black;
padding: 8px 10px;
}
.small {
padding: 20px;
font-size: 12px;
color: darkgray;
}
.restart {
width: 600px;
height: 75px;
}
</style>