| 1 | new Vue({ |
| 2 | el: "#mahjong", |
| 3 | data: { |
| 4 | players: [], //array of objects, filled later |
| 5 | display: "players", |
| 6 | }, |
| 7 | components: { |
| 8 | 'my-players': { |
| 9 | props: ['players'], |
| 10 | template: ` |
| 11 | <div id="players"> |
| 12 | <div class="left"> |
| 13 | <p>Présents</p> |
| 14 | <table class="list"> |
| 15 | <tr v-for="p in sortedPlayers" v-if="p.available" @click="toggleAvailability(p.index)"> |
| 16 | <td>{{ p.prenom }}</td> |
| 17 | <td>{{ p.nom }}</td> |
| 18 | </tr> |
| 19 | </table> |
| 20 | </div> |
| 21 | <div id="inactive" class="right"> |
| 22 | <p>Absents</p> |
| 23 | <table class="list"> |
| 24 | <tr v-for="p in sortedPlayers" v-if="!p.available && p.nom!=''" @click="toggleAvailability(p.index)"> |
| 25 | <td>{{ p.prenom }}</td> |
| 26 | <td>{{ p.nom }}</td> |
| 27 | </tr> |
| 28 | </table> |
| 29 | </div> |
| 30 | </div> |
| 31 | `, |
| 32 | computed: { |
| 33 | sortedPlayers: function() { |
| 34 | return this.players |
| 35 | .map( (p,i) => { return Object.assign({}, p, {index: i}); }) |
| 36 | .sort( (a,b) => { |
| 37 | return a.nom.localeCompare(b.nom); |
| 38 | }); |
| 39 | }, |
| 40 | }, |
| 41 | methods: { |
| 42 | toggleAvailability: function(i) { |
| 43 | this.players[i].available = 1 - this.players[i].available; |
| 44 | this.$forceUpdate(); //TODO (Vue.set... ?!) |
| 45 | }, |
| 46 | }, |
| 47 | }, |
| 48 | 'my-pairings': { |
| 49 | props: ['players','writeScoreToDb'], |
| 50 | data: function() { |
| 51 | return { |
| 52 | unpaired: [], |
| 53 | tables: [], //array of arrays of players indices |
| 54 | sessions: [], //"mini-points" for each table |
| 55 | currentIndex: -1, //table index for scoring |
| 56 | scored: [], //boolean for each table index |
| 57 | }; |
| 58 | }, |
| 59 | template: ` |
| 60 | <div id="pairings"> |
| 61 | <div v-show="currentIndex < 0"> |
| 62 | <button id="runPairing" class="block btn" @click="doPairings()">Nouvelle ronde</button> |
| 63 | <div class="pairing" v-for="(table,index) in tables" :class="{scored: scored[index]}" |
| 64 | @click="showScoreForm(table,index)"> |
| 65 | <p>Table {{ index+1 }}</p> |
| 66 | <table> |
| 67 | <tr v-for="(i,j) in table"> |
| 68 | <td :class="{toto: players[i].prenom=='Toto'}">{{ players[i].prenom }} {{ players[i].nom }}</td> |
| 69 | <td class="score"><span v-show="sessions[index].length > 0">{{ sessions[index][j] }}</span></td> |
| 70 | </tr> |
| 71 | </table> |
| 72 | </div> |
| 73 | <div v-if="unpaired.length>0" class="pairing unpaired"> |
| 74 | <p>Exempts</p> |
| 75 | <div v-for="i in unpaired"> |
| 76 | {{ players[i].prenom }} {{ players[i].nom }} |
| 77 | </div> |
| 78 | </div> |
| 79 | </div> |
| 80 | <div id="scoreInput" v-if="currentIndex >= 0"> |
| 81 | <table> |
| 82 | <tr v-for="(index,i) in tables[currentIndex]"> |
| 83 | <td :class="{toto: players[tables[currentIndex][i]].prenom=='Toto'}"> |
| 84 | {{ players[tables[currentIndex][i]].prenom }} {{ players[tables[currentIndex][i]].nom }} |
| 85 | </td> |
| 86 | <td><input type="text" v-model="sessions[currentIndex][i]"/></td> |
| 87 | </tr> |
| 88 | </table> |
| 89 | <div class="button-container-horizontal"> |
| 90 | <button class="btn validate" @click="setScore()">Enregistrer</button> |
| 91 | <button class="btn" @click="currentIndex = -1">Fermer</button> |
| 92 | </div> |
| 93 | <div v-if="scored[currentIndex]" class="warning"> |
| 94 | Attention: un score a déjà été enregistré. |
| 95 | Les points indiqués ici s'ajouteront : il faut d'abord |
| 96 | <span class="link" @click="clickRestore()">restaurer l'état précédent.</span> |
| 97 | Si c'est déjà fait, ignorer ce message :) |
| 98 | </div> |
| 99 | </div> |
| 100 | </div> |
| 101 | `, |
| 102 | methods: { |
| 103 | doPairings: function() { |
| 104 | // Simple case first: 4 by 4 |
| 105 | let tables = []; |
| 106 | let currentTable = []; |
| 107 | let ordering = _.shuffle(_.range(this.players.length)); |
| 108 | for (let i=0; i<ordering.length; i++) |
| 109 | { |
| 110 | if ( ! this.players[ordering[i]].available ) |
| 111 | continue; |
| 112 | if (currentTable.length >= 4) |
| 113 | { |
| 114 | tables.push(currentTable); |
| 115 | currentTable = []; |
| 116 | } |
| 117 | currentTable.push(ordering[i]); |
| 118 | } |
| 119 | // Analyse remainder |
| 120 | this.unpaired = []; |
| 121 | if (currentTable.length != 0) |
| 122 | { |
| 123 | if (currentTable.length < 3) |
| 124 | { |
| 125 | let missingPlayers = 3 - currentTable.length; |
| 126 | // Pick players from 'missingPlayers' random different tables, if possible |
| 127 | if (tables.length >= missingPlayers) |
| 128 | { |
| 129 | let tblNums = _.sample(_.range(tables.length), missingPlayers); |
| 130 | tblNums.forEach( num => { |
| 131 | currentTable.push(tables[num].pop()); |
| 132 | }); |
| 133 | } |
| 134 | } |
| 135 | if (currentTable.length >= 3) |
| 136 | tables.push(currentTable); |
| 137 | else |
| 138 | this.unpaired = currentTable; |
| 139 | } |
| 140 | // Ensure that all tables have 4 players |
| 141 | tables.forEach( t => { |
| 142 | if (t.length < 4) |
| 143 | t.push(0); //index of "Toto", ghost player |
| 144 | }); |
| 145 | this.tables = tables; |
| 146 | this.sessions = tables.map( t => { return []; }); //empty sessions |
| 147 | this.scored = tables.map( t => { return false; }); //nothing scored yet |
| 148 | this.currentIndex = -1; //required if reset while scoring |
| 149 | }, |
| 150 | showScoreForm: function(table,index) { |
| 151 | if (this.sessions[index].length == 0) |
| 152 | this.sessions[index] = _.times(table.length, _.constant(0)); |
| 153 | this.currentIndex = index; |
| 154 | }, |
| 155 | setScore: function() { |
| 156 | let sortedSessions = this.sessions[this.currentIndex] |
| 157 | .map( (s,i) => { return {value:s, index:i}; }) |
| 158 | .sort( (a,b) => { return parseInt(b.value) - parseInt(a.value); }); |
| 159 | let pdts = [4, 2, 1, 0]; |
| 160 | // NOTE: take care of ex-aequos (spread points subtotal) |
| 161 | let curSum = 0, curCount = 0, start = 0; |
| 162 | for (let i=0; i<4; i++) |
| 163 | { |
| 164 | // Update pdts: |
| 165 | curSum += pdts[i]; |
| 166 | curCount++; |
| 167 | if (i==3 || sortedSessions[i].value > sortedSessions[i+1].value) |
| 168 | { |
| 169 | let pdt = curSum / curCount; |
| 170 | for (let j=start; j<=i; j++) |
| 171 | this.players[this.tables[this.currentIndex][sortedSessions[j].index]].pdt += pdt; |
| 172 | curSum = 0; |
| 173 | curCount = 0; |
| 174 | start = i+1; |
| 175 | } |
| 176 | // Update sessions: |
| 177 | this.players[this.tables[this.currentIndex][i]].session += parseInt(this.sessions[this.currentIndex][i]); |
| 178 | } |
| 179 | this.scored[this.currentIndex] = true; |
| 180 | this.currentIndex = -1; |
| 181 | this.writeScoreToDb(); |
| 182 | }, |
| 183 | clickRestore: function() { |
| 184 | document.getElementById('restoreBtn').click(); |
| 185 | }, |
| 186 | }, |
| 187 | }, |
| 188 | 'my-timer': { |
| 189 | data: function() { |
| 190 | return { |
| 191 | time: 0, //remaining time, in seconds |
| 192 | running: false, |
| 193 | }; |
| 194 | }, |
| 195 | template: ` |
| 196 | <div id="timer" :style="{lineHeight: textHeight + 'px', fontSize: 0.66*textHeight + 'px'}"> |
| 197 | <div @click.left="pauseResume()" @click.right.prevent="reset()" :class="{timeout:time==0}"> |
| 198 | {{ formattedTime }} |
| 199 | </div> |
| 200 | <img class="close-cross" src="img/cross.svg" @click="$emit('clockover')"/> |
| 201 | </div> |
| 202 | `, |
| 203 | computed: { |
| 204 | formattedTime: function() { |
| 205 | let seconds = this.time % 60; |
| 206 | let minutes = Math.floor(this.time / 60); |
| 207 | return this.padToZero(minutes) + ":" + this.padToZero(seconds); |
| 208 | }, |
| 209 | textHeight: function() { |
| 210 | return screen.height; |
| 211 | }, |
| 212 | }, |
| 213 | methods: { |
| 214 | padToZero: function(a) { |
| 215 | if (a < 10) |
| 216 | return "0" + a; |
| 217 | return a; |
| 218 | }, |
| 219 | pauseResume: function() { |
| 220 | this.running = !this.running; |
| 221 | if (this.running) |
| 222 | this.start(); |
| 223 | }, |
| 224 | reset: function(e) { |
| 225 | this.running = false; |
| 226 | this.time = 5400; //1:30 |
| 227 | }, |
| 228 | start: function() { |
| 229 | if (!this.running) |
| 230 | return; |
| 231 | if (this.time == 0) |
| 232 | { |
| 233 | new Audio("sounds/gong.mp3").play(); |
| 234 | this.running = false; |
| 235 | return; |
| 236 | } |
| 237 | setTimeout(() => { |
| 238 | if (this.running) |
| 239 | this.time--; |
| 240 | this.start(); |
| 241 | }, 1000); |
| 242 | }, |
| 243 | }, |
| 244 | created: function() { |
| 245 | this.reset(); |
| 246 | }, |
| 247 | }, |
| 248 | 'my-ranking': { |
| 249 | props: ['players','sortByScore','writeScoreToDb'], |
| 250 | template: ` |
| 251 | <div id="ranking"> |
| 252 | <table class="ranking"> |
| 253 | <tr class="title"> |
| 254 | <th>Rang</th> |
| 255 | <th>Joueur</th> |
| 256 | <th>Points</th> |
| 257 | <th>Mini-pts</th> |
| 258 | </tr> |
| 259 | <tr v-for="p in sortedPlayers"> |
| 260 | <td>{{ p.rank }}</td> |
| 261 | <td>{{ p.prenom }} {{ p.nom }}</td> |
| 262 | <td>{{ p.pdt }}</td> |
| 263 | <td>{{ p.session }}</td> |
| 264 | </tr> |
| 265 | </table> |
| 266 | <div class="button-container-vertical" style="width:200px"> |
| 267 | <button class="btn cancel" @click="resetPlayers()">Réinitialiser</button> |
| 268 | <button id="restoreBtn" class="btn" @click="restoreLast()">Restaurer</button> |
| 269 | </div> |
| 270 | </div> |
| 271 | `, |
| 272 | computed: { |
| 273 | sortedPlayers: function() { |
| 274 | let res = this.rankPeople(); |
| 275 | // Add rank information (taking care of ex-aequos) |
| 276 | let rank = 1; |
| 277 | for (let i=0; i<res.length; i++) |
| 278 | { |
| 279 | if (i==0 || this.sortByScore(res[i],res[i-1]) == 0) |
| 280 | res[i].rank = rank; |
| 281 | else //strictly lower scoring |
| 282 | res[i].rank = ++rank; |
| 283 | } |
| 284 | return res; |
| 285 | }, |
| 286 | }, |
| 287 | methods: { |
| 288 | rankPeople: function() { |
| 289 | return this.players |
| 290 | .slice(1) //discard Toto |
| 291 | .sort(this.sortByScore); |
| 292 | }, |
| 293 | resetPlayers: function() { |
| 294 | this.players |
| 295 | .slice(1) //discard Toto |
| 296 | .forEach( p => { |
| 297 | p.pdt = 0; |
| 298 | p.session = 0; |
| 299 | p.available = 1; |
| 300 | }); |
| 301 | this.writeScoreToDb(); |
| 302 | document.getElementById("runPairing").click(); |
| 303 | }, |
| 304 | restoreLast: function() { |
| 305 | let xhr = new XMLHttpRequest(); |
| 306 | let self = this; |
| 307 | xhr.onreadystatechange = function() { |
| 308 | if (this.readyState == 4 && this.status == 200) |
| 309 | { |
| 310 | let players = JSON.parse(xhr.responseText); |
| 311 | if (players.length > 0) |
| 312 | { |
| 313 | players.unshift({ //add ghost 4th player for 3-players tables |
| 314 | prenom: "Toto", |
| 315 | nom: "", |
| 316 | pdt: 0, |
| 317 | session: 0, |
| 318 | available: 0, |
| 319 | }); |
| 320 | // NOTE: Vue warning "do not mutate property" if direct self.players = players |
| 321 | for (let i=1; i<players.length; i++) |
| 322 | { |
| 323 | players[i].pdt = parseFloat(players[i].pdt); |
| 324 | players[i].session = parseInt(players[i].session); |
| 325 | Vue.set(self.players, i, players[i]); |
| 326 | } |
| 327 | } |
| 328 | } |
| 329 | }; |
| 330 | xhr.open("GET", "scripts/rw_players.php?restore=1", true); |
| 331 | xhr.send(null); |
| 332 | }, |
| 333 | }, |
| 334 | }, |
| 335 | }, |
| 336 | created: function() { |
| 337 | let xhr = new XMLHttpRequest(); |
| 338 | let self = this; |
| 339 | xhr.onreadystatechange = function() { |
| 340 | if (this.readyState == 4 && this.status == 200) |
| 341 | { |
| 342 | let players = JSON.parse(xhr.responseText); |
| 343 | players.forEach( p => { |
| 344 | p.pdt = !!p.pdt ? parseFloat(p.pdt) : 0; |
| 345 | p.session = !!p.session ? parseInt(p.session) : 0; |
| 346 | p.available = !!p.available ? p.available : 1; //use integer for fputcsv PHP func |
| 347 | }); |
| 348 | players.unshift({ //add ghost 4th player for 3-players tables |
| 349 | prenom: "Toto", |
| 350 | nom: "", |
| 351 | pdt: 0, |
| 352 | session: 0, |
| 353 | available: 0, |
| 354 | }); |
| 355 | self.players = players; |
| 356 | } |
| 357 | }; |
| 358 | xhr.open("GET", "scripts/rw_players.php", true); |
| 359 | xhr.send(null); |
| 360 | }, |
| 361 | methods: { |
| 362 | // Used both in ranking and pairings: |
| 363 | sortByScore: function(a,b) { |
| 364 | return b.pdt - a.pdt + (Math.atan(b.session - a.session) / (Math.PI/2)) / 2; |
| 365 | }, |
| 366 | writeScoreToDb: function() { |
| 367 | let xhr = new XMLHttpRequest(); |
| 368 | xhr.open("POST", "scripts/rw_players.php"); |
| 369 | xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); |
| 370 | let orderedPlayers = this.players |
| 371 | .slice(1) //discard Toto |
| 372 | .sort(this.sortByScore); |
| 373 | xhr.send("players="+encodeURIComponent(JSON.stringify(orderedPlayers))); |
| 374 | }, |
| 375 | }, |
| 376 | }); |