First commit
authorBenjamin Auder <benjamin.auder@somewhere>
Sun, 24 Oct 2021 21:14:11 +0000 (23:14 +0200)
committerBenjamin Auder <benjamin.auder@somewhere>
Sun, 24 Oct 2021 21:14:11 +0000 (23:14 +0200)
19 files changed:
.gitignore [new file with mode: 0644]
README.md [new file with mode: 0644]
api.py [new file with mode: 0644]
assets/CREDITS [new file with mode: 0644]
assets/Lizard.png [new file with mode: 0644]
assets/Paper.png [new file with mode: 0644]
assets/Rock.png [new file with mode: 0644]
assets/Scissors.png [new file with mode: 0644]
assets/Spock.png [new file with mode: 0644]
assets/rpsls.css [new file with mode: 0644]
assets/searching.gif [new file with mode: 0644]
assets/what.png [new file with mode: 0644]
db/rpsls.sqlite [new file with mode: 0644]
db/setup.sql [new file with mode: 0644]
favicon.ico [new file with mode: 0644]
index.html [new file with mode: 0644]
requirements.txt [new file with mode: 0644]
rpsls.js [new file with mode: 0644]
server.py [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..00fe2a3
--- /dev/null
@@ -0,0 +1,3 @@
+/db/db.sqlite
+*.swp
+*~
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..ad19196
--- /dev/null
+++ b/README.md
@@ -0,0 +1,12 @@
+## Mise en place
+
+Dans le dossier db/ : `sqlite3 rpsls.sqlite` <br>
+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 (file)
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 (file)
index 0000000..068fabb
--- /dev/null
@@ -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 (file)
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 (file)
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 (file)
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 (file)
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 (file)
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 (file)
index 0000000..6a02e6d
--- /dev/null
@@ -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 (file)
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 (file)
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 (file)
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 (file)
index 0000000..f850b63
--- /dev/null
@@ -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 (file)
index 0000000..9144ebc
Binary files /dev/null and b/favicon.ico differ
diff --git a/index.html b/index.html
new file mode 100644 (file)
index 0000000..317227a
--- /dev/null
@@ -0,0 +1,14 @@
+<!doctype html>
+<html>
+       <head>
+               <meta charset="utf-8">
+               <title>rpsls</title>
+    <link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,700;1,400&display=swap" rel="stylesheet">
+               <link rel="stylesheet" href="assets/rpsls.css">
+       </head>
+       <body>
+    <script src="https://unpkg.com/mithril@2.0.4/mithril.min.js"></script>
+    <script src="https://unpkg.com/socket.io-client@4.3.2/dist/socket.io.min.js"></script>
+    <script src="rpsls.js"></script>
+       </body>
+</html>
diff --git a/requirements.txt b/requirements.txt
new file mode 100644 (file)
index 0000000..cf48de7
--- /dev/null
@@ -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 (file)
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 (file)
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)