From: Benjamin Auder Date: Sun, 24 Oct 2021 21:14:11 +0000 (+0200) Subject: First commit X-Git-Url: https://git.auder.net/doc/html/%7B%7B%20path%28%27mixstore_static_policy%27%29%20%7D%7D?a=commitdiff_plain;h=bbb90bbafb330b3eeaf95074859149a50884217e;p=rpsls-web.git First commit --- bbb90bbafb330b3eeaf95074859149a50884217e diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00fe2a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/db/db.sqlite +*.swp +*~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad19196 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +## Mise en place + +Dans le dossier db/ : `sqlite3 rpsls.sqlite`
+Puis à l'invite de commande SQLite : `.read setup.sql` + +Ensuite, dans ce répertoire : `pip install -r requirements.txt` + +## Utilisation (en local) + +À la racine du projet dans un terminal : `python server.py` + +Naviguez ensuite sur [http://127.0.0.1:8000/](http://127.0.0.1:8000/) diff --git a/api.py b/api.py new file mode 100644 index 0000000..d0813a3 --- /dev/null +++ b/api.py @@ -0,0 +1,28 @@ +# TODO: utiliser hug https://www.hug.rest/website/quickstart +# pour implémenter une API permettant d'obtenir : + +# - les parties d'un joueur donné / avec ou sans les coups ? +# (contre un joueur précis, qu'il a gagnées/perdues, à la date D...) +# - des joueurs par identifiant(s), ou par motifs sur le nom. + +# L'idée étant, par exemple, de pouvoir créer un site sur lequel +# s'afficherait un "leaderboard" (comme sur fishrandom.io), et où +# on pourrait rejouer des parties, etc. + +# => Requêtes plus complexes à construire ensuite ; exemples : +# - nombre maximal de parties gagnées d'affilée ? +# - pourcentage de victoires ? + +# On peut aussi imaginer écrire dans la base depuis l'API : +# par exemple pour permettre de jouer des tournois. +# --> création d'une table "Tournaments", +# ajout d'un champ "tid" dans la table Games (NULL par défaut), +# ajout d'un paramètre optionnel après #!/play : ID de partie +# (permet de récupérer l'adversaire ID + nom via XHR ...etc) +# (si adversaire absent à la connexion, le signaler et attendre) + +RPSLS_PATH = './' #edit if launched from elsewhere +DB_PATH = RPSLS_PATH + 'db/rpsls.sqlite' +# ... + +# Extension : on peut aussi permettre de choisir le nombre de rounds. diff --git a/assets/CREDITS b/assets/CREDITS new file mode 100644 index 0000000..068fabb --- /dev/null +++ b/assets/CREDITS @@ -0,0 +1,13 @@ +Rock, Paper and Scissors images are from https://www.iconfinder.com/ +(they are still striped, as printed on the website, since I didn't pay for them) + +Spock image was found here http://all-free-download.com/free-vector/download/spock-star-trek-cartoon_52494.html + +Lizard image was found here http://www.symbols-n-emoticons.com/2014/07/likable-lizard.html + +favicon from here https://fr.findicons.com/icon/441883/applications_games + +GIF source: https://www.pinterest.com/pin/587930926353765433/ +(edited with https://ezgif.com/) + +Question mark: https://fr.m.wikipedia.org/wiki/Fichier:Question_mark_alternate.svg diff --git a/assets/Lizard.png b/assets/Lizard.png new file mode 100644 index 0000000..78ae838 Binary files /dev/null and b/assets/Lizard.png differ diff --git a/assets/Paper.png b/assets/Paper.png new file mode 100644 index 0000000..e07b3d6 Binary files /dev/null and b/assets/Paper.png differ diff --git a/assets/Rock.png b/assets/Rock.png new file mode 100644 index 0000000..198c0ce Binary files /dev/null and b/assets/Rock.png differ diff --git a/assets/Scissors.png b/assets/Scissors.png new file mode 100644 index 0000000..c70f9e5 Binary files /dev/null and b/assets/Scissors.png differ diff --git a/assets/Spock.png b/assets/Spock.png new file mode 100644 index 0000000..799c67a Binary files /dev/null and b/assets/Spock.png differ diff --git a/assets/rpsls.css b/assets/rpsls.css new file mode 100644 index 0000000..6a02e6d --- /dev/null +++ b/assets/rpsls.css @@ -0,0 +1,58 @@ +body { + text-align: center; + font-family: Merriweather, serif; + font-size: 24px; +} + +input { + display: block; + margin: 50px auto; + line-height: 42px; + padding-left: 10px; + font-size: 24px; +} + +button { + background-color: #4CAF50; /* Green */ + border: none; + color: white; + padding: 15px 32px; + text-decoration: none; + font-size: 24px; + display: block; + margin: 50px auto; + cursor: pointer; +} + +.options { + width: 800px; + margin: 0 auto 30px auto; +} + +.options img { + display: inline-block; + width: 20%; + margin: 0; +} + +.options img:hover { + cursor: pointer; + background-color: lightblue; +} + +.choices { + width: 320px; + margin: 0 auto 30px auto; +} + +.choices img { + display: inline-block; + width: 50%; + margin: 0; +} + +img.animated { + display: block; + width: 200px; + margin: 50px auto; +} diff --git a/assets/searching.gif b/assets/searching.gif new file mode 100644 index 0000000..06e2699 Binary files /dev/null and b/assets/searching.gif differ diff --git a/assets/what.png b/assets/what.png new file mode 100644 index 0000000..7c5d659 Binary files /dev/null and b/assets/what.png differ diff --git a/db/rpsls.sqlite b/db/rpsls.sqlite new file mode 100644 index 0000000..7f0d680 Binary files /dev/null and b/db/rpsls.sqlite differ diff --git a/db/setup.sql b/db/setup.sql new file mode 100644 index 0000000..f850b63 --- /dev/null +++ b/db/setup.sql @@ -0,0 +1,28 @@ +-- A user may or may not play games +create table Users( + id integer primary key, + name varchar(32) unique not null +); + +create table Games( + id integer primary key, + created date +); + +-- A player (ref. uid) is involved into a game (ref. gid) +create table Players( + uid integer, + gid integer, + points integer not null default 0, + foreign key(uid) references Users(id), + foreign key(gid) references Games(id) +); + +create table Moves( + uid integer, + gid integer, + choice character(1) not null, --'r', 'p', 's', 'l' or 'k' + mnum integer not null, + foreign key(uid) references Users(id), + foreign key(gid) references Games(id) +); diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..9144ebc Binary files /dev/null and b/favicon.ico differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..317227a --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + rpsls + + + + + + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cf48de7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +eventlet==0.32.0 +python-socketio==5.4.1 diff --git a/rpsls.js b/rpsls.js new file mode 100644 index 0000000..92ec9fc --- /dev/null +++ b/rpsls.js @@ -0,0 +1,150 @@ +const socket = io(); + +const getV = (field) => localStorage.getItem(field); + +const Login = { + view: function() { + return m("input[type=text][placeholder=User name]", { + autofocus: true, + onchange(e) {Login.username = e.currentTarget.value}, + onkeyup: Login.process + }); + }, + process: function(e) { + if (e.keyCode == 13) socket.emit("login", e.target.value); + } +}; + +let Seek = { + searching: false, + seekGame: function() { + // If not logged in, redirect + if (!getV("name")) m.route.set("/login"); + else { + socket.emit("seek", {uid: getV("uid"), name: getV("name")}); + Seek.searching = true; + } + }, + view: function(vnode) { + if (Seek.searching) + return m("img.animated", {src: "assets/searching.gif"}); + return m("button", {onclick: Seek.seekGame}, "Play"); + } +}; + +const Choices = + {'r':"Rock",'p':"Paper",'s':"Scissors",'l':"Lizard",'k':"Spock",'?':"what"}; +const Win = + {'r':['s','l'], 'p':['r','k'], 's':['p','l'], 'l':['p','k'], 'k':['r','s']}; +const MAX_POINTS = 7; + +let Play = { + gid: 0, + mymove: "", + oppmove: "", + myselect: "?", + oppselect: "?", + mypoints: 0, + oppoints: 0, + mnum: 0, + oppid: 0, + oppname: "", + gameover: false, + initGame: function(msg) { + Play.gid = msg.gid; + Play.oppid = msg.oppid; + Play.oppname = msg.oppname; + Play.gameover = false; + for (const v of ["mymove","oppmove"]) Play[v] = ""; + for (const v of ["myselect","oppselect"]) Play[v] = "?"; + for (const v of ["mnum","mypoints","oppoints"]) Play[v] = 0; + }, + compareMoves: function() { + Play.oppselect = Play.oppmove; //reveal opponent's move only now + if (Win[Play.mymove].includes(Play.oppmove)) { + if (++Play.mypoints == MAX_POINTS) Play.endGame(true); + } + else if (Win[Play.oppmove].includes(Play.mymove)) { + if (++Play.oppoints == MAX_POINTS) Play.endGame(false); + } + Play.mnum++; + Play.mymove = ""; + Play.oppmove = ""; + }, + endGame: function(Iwin) { + Play.gameover = true; + setTimeout(() => { + if (Iwin) alert("Bravo t'as gagné !"); + else alert("Pas de chance, t'as perdu..."); + m.route.set("/seek"); + }, 1000); + }, + playMove: function(code) { + if (Play.mymove || Play.gameover) + // I already played, or game is over + return; + Play.mymove = code; + Play.myselect = code; + socket.emit("move", { + uid: getV("uid"), + gid: Play.gid, + choice: Play.mymove, + mnum: Play.mnum, + oppid: Play.oppid + }); + if (Play.oppmove) Play.compareMoves(); + else Play.oppselect = "?"; + }, + view: function() { + return m("div", {}, + [m("h2", {}, + `${getV("name")} [${Play.mypoints}] vs. ${Play.oppname} [${Play.oppoints}]`)] + .concat( + [m(".choices", {}, [ + m("img", {src: "assets/" + Choices[Play.myselect] + ".png"}), + m("img", {src: "assets/" + Choices[Play.oppselect] + ".png"}) + ])]).concat( + [m(".options", + {style: `opacity:${Play.mymove==''?'1':'0.5'}`}, + ["r","p","s","l","k"].map((code) => { + return m("img", { + src: "assets/" + Choices[code] + ".png", + onclick: () => Play.playMove(code) + }) + }))]) + ); + } +}; + +socket.on("login", (msg) => { + if (!msg.err) { + localStorage.setItem("name", msg.name); + localStorage.setItem("uid", msg.uid); + m.route.set("/seek"); + } + else alert(msg.err); +}); +socket.on("play", (msg) => { + Seek.searching = false; + if (msg.oppid == getV("uid")) { + alert("Cannot play against self!"); + m.redraw(); //TODO: because no DOM interaction... ? + } + else { + Play.initGame(msg); + m.route.set("/play"); + } +}); +socket.on("move", (msg) => { + Play.oppmove = msg.choice; + Play.oppselect = "?"; //not showing opponent selection yet! + if (Play.mymove) Play.compareMoves(); + else Play.myselect = "?"; + m.redraw(); //TODO... (because no DOM interactions) +}); + +m.route(document.body, "/seek", { + "/seek": Seek, + "/play": Play, + "/login": Login +}); diff --git a/server.py b/server.py new file mode 100644 index 0000000..61a01aa --- /dev/null +++ b/server.py @@ -0,0 +1,108 @@ +import socketio +import eventlet +import sqlite3 +import re +from datetime import date +import os + +# Create a Socket.IO server (CORS arg required on server, not locally) +sio = socketio.Server() +#sio = socketio.Server(cors_allowed_origins='URL or *') + +RPSLS_PATH = './' #edit if launched from elsewhere +DB_PATH = RPSLS_PATH + 'db/rpsls.sqlite' + +searching = {} #someone seeks a game? (uid + sid) +connected = {} #map uid --> sid (seek stage) + +@sio.event +def disconnect(sid): + """ Triggered at page reload or tab close """ + global connected, searching + try: + key_idx = list(connected.values()).index(sid) + del connected[list(connected.keys())[key_idx]] + except ValueError: + # If the user didn't seek, no key to find + pass + if searching and searching["sid"] == sid: + searching = {} + +@sio.event +def login(sid, data): + """ When user sends name from /login page """ + if not re.match(r"^[a-zA-Z]{3,}$", data): + sio.emit("login", {"err": "Name: letters only"}, room=sid) + return + con = sqlite3.connect(DB_PATH) + cur = con.cursor() + uid = 0 + try: + # Always try to insert (new) Users row + cur.execute("insert into Users (name) values (?)", (data,)) + uid = cur.lastrowid + except sqlite3.IntegrityError as err: + # If fails: user already exists, find its ID + if str(err) == "UNIQUE constraint failed: Users.name": + cur.execute("select id from Users where name = ?", (data,)) + uid = cur.fetchone()[0] + else: + raise + con.commit() + con.close() + sio.emit("login", {"name": data, "uid": uid}, room=sid) + +@sio.event +def seek(sid, data): + """ When user click on 'Play' button """ + global connected, searching + connected[data["uid"]] = sid + if not searching: + searching = {"uid": data["uid"], "sid": sid, "name": data["name"]} + else: + # Active seek pending: create game + opponent = searching + searching = {} + con = sqlite3.connect(DB_PATH) + cur = con.cursor() + today = (date.today(),) + cur.execute("insert into Games (created) values (?)", today) + gid = cur.lastrowid + # To room == sid, opponent is me. To my room, it's him/her + sio.emit("play", + {"gid":gid, "oppid":opponent["uid"], "oppname":opponent["name"]}, + room=sid) + sio.emit("play", + {"gid":gid, "oppid":data["uid"], "oppname":data["name"]}, + room=opponent["sid"]) + id_list = [(data["uid"],gid), (opponent["uid"],gid)] + cur.executemany("insert into Players (uid,gid) values (?,?)", id_list) + con.commit() + con.close() + +@sio.event +def move(sid, data): + """ New move to DB + transmit to opponent """ + sio.emit("move", data, room=connected[data["oppid"]]) + con = sqlite3.connect(DB_PATH) + cur = con.cursor() + cur.execute("insert into Moves (uid,gid,choice,mnum) values (?,?,?,?)", + (data["uid"],data["gid"],data["choice"],data["mnum"])) + con.commit() + con.close() + +static_files = { + '/': RPSLS_PATH + 'index.html', + '/rpsls.js': RPSLS_PATH + 'rpsls.js', + '/favicon.ico': RPSLS_PATH + 'favicon.ico', + '/assets': RPSLS_PATH + 'assets' +} + +PORT = os.getenv('RPSLS_PORT') +if PORT is None: + PORT = "8000" +PORT = int(PORT) + +# Wrap with a WSGI application +app = socketio.WSGIApp(sio, static_files=static_files) +eventlet.wsgi.server(eventlet.listen(('127.0.0.1', PORT)), app)