This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
name: Deploy
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
# Doit correspondre à un label de ton act_runner, installé sur le serveur qui héberge Docker
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build et redémarrage du conteneur
|
||||||
|
run: docker compose up -d --build --remove-orphans
|
||||||
|
- name: Nettoyage des anciennes images
|
||||||
|
run: docker image prune -f
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
COPY . .
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
USER node
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/healthz || exit 1
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
name: blindtest
|
||||||
|
services:
|
||||||
|
blindtest:
|
||||||
|
build: .
|
||||||
|
container_name: blindtest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "blindtest",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": { "start": "node server.js" },
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"socket.io": "^4.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||||
|
<title>Blind Test – Buzzer</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="buzzer-page">
|
||||||
|
<!-- Saisie du nom -->
|
||||||
|
<div id="joinView" class="card" hidden>
|
||||||
|
<h2>Rejoindre la partie</h2>
|
||||||
|
<div class="row">
|
||||||
|
<input id="name" type="text" placeholder="Ton nom" maxlength="20" autocomplete="off">
|
||||||
|
<button id="joinBtn" class="primary">Rejoindre</button>
|
||||||
|
</div>
|
||||||
|
<p id="joinError" class="error"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buzzer -->
|
||||||
|
<div id="playView" hidden style="display:none;flex-direction:column;align-items:center;gap:20px;width:100%">
|
||||||
|
<div class="me"><span id="meName"></span><span id="meScore"></span></div>
|
||||||
|
<button id="buzz" disabled>BUZZ</button>
|
||||||
|
<div id="msg"></div>
|
||||||
|
<ul id="board" class="board" style="width:100%;max-width:420px"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script>
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const roomId = (params.get('room') || '').toUpperCase();
|
||||||
|
const KEY = 'bt_player';
|
||||||
|
let saved = JSON.parse(localStorage.getItem(KEY) || 'null') || {};
|
||||||
|
if (saved.roomId !== roomId) saved = { name: saved.name || '' }; // autre room : on garde juste le nom
|
||||||
|
let me = null;
|
||||||
|
let state = { phase: 'lobby', players: [], buzzer: null, lockedOut: [] };
|
||||||
|
const socket = io();
|
||||||
|
|
||||||
|
function showJoin(err) {
|
||||||
|
$('joinView').hidden = false;
|
||||||
|
$('playView').style.display = 'none';
|
||||||
|
if (err) $('joinError').textContent = err;
|
||||||
|
if (!$('name').value) $('name').value = saved.name || '';
|
||||||
|
}
|
||||||
|
function join(name, silent) {
|
||||||
|
if (!roomId) return showJoin('Scanne le QR code affiché sur l\'écran principal.');
|
||||||
|
socket.emit('player:join', { roomId, token: saved.token, name }, res => {
|
||||||
|
if (res.error) {
|
||||||
|
if (res.error === 'no_room') {
|
||||||
|
if (silent && me) return setTimeout(() => join(name, true), 2000); // le serveur a peut-être redémarré
|
||||||
|
return showJoin('Partie introuvable. Rescanne le QR code.');
|
||||||
|
}
|
||||||
|
if (res.error === 'name_taken') return showJoin('Ce nom est déjà pris.');
|
||||||
|
return showJoin('Entre un nom.');
|
||||||
|
}
|
||||||
|
me = res;
|
||||||
|
saved = { roomId, token: res.token, name: res.name };
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(saved));
|
||||||
|
$('joinView').hidden = true;
|
||||||
|
$('playView').style.display = 'flex';
|
||||||
|
$('meName').textContent = res.name;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
socket.on('connect', () => {
|
||||||
|
if (saved.token && saved.name) join(saved.name, true);
|
||||||
|
else showJoin();
|
||||||
|
});
|
||||||
|
socket.on('state', s => { state = s; render(); });
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!me) return;
|
||||||
|
const mine = state.players.find(p => p.id === me.id);
|
||||||
|
$('meScore').textContent = mine ? `${mine.score} pt${mine.score > 1 ? 's' : ''}` : '';
|
||||||
|
const locked = state.lockedOut.includes(me.id);
|
||||||
|
const buzz = $('buzz');
|
||||||
|
let msg = '', enabled = false, label = 'BUZZ';
|
||||||
|
switch (state.phase) {
|
||||||
|
case 'lobby': msg = 'En attente du prochain morceau…'; label = '⏳'; break;
|
||||||
|
case 'playing': if (locked) { msg = 'Bloqué pour ce morceau'; label = '🚫'; } else { enabled = true; msg = 'Buzze !'; } break;
|
||||||
|
case 'buzzed': msg = state.buzzer && state.buzzer.id === me.id ? 'À toi de répondre !' : `${state.buzzer ? state.buzzer.name : ''} a buzzé`; label = '🔔'; break;
|
||||||
|
case 'result': msg = state.buzzer ? `${state.buzzer.name} marque un point !` : ''; label = '✅'; break;
|
||||||
|
case 'ended': msg = 'Partie terminée !'; label = '🏆'; break;
|
||||||
|
}
|
||||||
|
buzz.disabled = !enabled;
|
||||||
|
buzz.textContent = label;
|
||||||
|
$('msg').textContent = msg;
|
||||||
|
|
||||||
|
const board = $('board'); board.textContent = '';
|
||||||
|
[...state.players].sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).forEach(p => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const n = document.createElement('span'); n.className = 'name'; n.textContent = p.name;
|
||||||
|
const s = document.createElement('span'); s.className = 'score'; s.textContent = p.score;
|
||||||
|
li.append(n, s); board.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$('buzz').addEventListener('pointerdown', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if ($('buzz').disabled) return;
|
||||||
|
socket.emit('player:buzz');
|
||||||
|
if (navigator.vibrate) navigator.vibrate(80);
|
||||||
|
});
|
||||||
|
$('joinBtn').addEventListener('click', () => { $('joinError').textContent = ''; join($('name').value, false); });
|
||||||
|
$('name').addEventListener('keydown', e => { if (e.key === 'Enter') $('joinBtn').click(); });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Blind Test – Master</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="master">
|
||||||
|
<header><h1>🎧 Blind Test</h1><div id="roomTag" class="tag"></div></header>
|
||||||
|
|
||||||
|
<div id="mainLayout" class="layout">
|
||||||
|
<section>
|
||||||
|
<!-- Lobby : QR code + saisie de l'URL -->
|
||||||
|
<div id="lobby" class="card">
|
||||||
|
<div class="lobby-grid">
|
||||||
|
<div class="qr">
|
||||||
|
<img id="qr" alt="QR code pour rejoindre la partie">
|
||||||
|
<p id="joinUrl" class="small"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Nouveau morceau</h2>
|
||||||
|
<p class="muted">Colle l'URL d'une vidéo YouTube puis valide.</p>
|
||||||
|
<div class="row">
|
||||||
|
<input id="url" type="url" placeholder="https://www.youtube.com/watch?v=…" autocomplete="off">
|
||||||
|
<button id="go" class="primary">Valider</button>
|
||||||
|
</div>
|
||||||
|
<p id="urlError" class="error"></p>
|
||||||
|
<button id="endFromLobby" class="ghost">Terminer la partie</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jeu : vidéo + buzz -->
|
||||||
|
<div id="game" class="card" hidden>
|
||||||
|
<div class="video">
|
||||||
|
<div id="yt"></div>
|
||||||
|
<div id="mask" class="mask" hidden>🎵</div>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
<div id="judge" class="judge" hidden>
|
||||||
|
<button id="btnTrue" class="ok">✅ Vrai</button>
|
||||||
|
<button id="btnFalse" class="ko">❌ Faux</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="next" class="primary"></button>
|
||||||
|
<button id="endBtn" class="ghost">Terminer la partie</button>
|
||||||
|
<label><input type="checkbox" id="hide"> Masquer la vidéo</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fin de partie -->
|
||||||
|
<div id="endScreen" class="card" hidden>
|
||||||
|
<h2>🏆 Résultats</h2>
|
||||||
|
<ol id="final" class="board final"></ol>
|
||||||
|
<button id="newGame" class="primary">Nouvelle partie</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside id="side" class="card">
|
||||||
|
<h2>Participants</h2>
|
||||||
|
<ul id="board" class="board"></ul>
|
||||||
|
<p id="empty" class="muted">Personne n'a encore rejoint.</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script>
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const socket = io();
|
||||||
|
let roomId = localStorage.getItem('bt_room');
|
||||||
|
let state = { phase: 'lobby', players: [], buzzer: null, lockedOut: [] };
|
||||||
|
let nextVideo = null;
|
||||||
|
|
||||||
|
/* ---------- YouTube ---------- */
|
||||||
|
let yt = null, ytReady = false, queued = null;
|
||||||
|
const apiTag = document.createElement('script');
|
||||||
|
apiTag.src = 'https://www.youtube.com/iframe_api';
|
||||||
|
document.head.appendChild(apiTag);
|
||||||
|
window.onYouTubeIframeAPIReady = () => { if (queued && !yt) createPlayer(); };
|
||||||
|
function createPlayer() {
|
||||||
|
yt = new YT.Player('yt', {
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
playerVars: { autoplay: 1, rel: 0, playsinline: 1, origin: location.origin },
|
||||||
|
events: { onReady: () => { ytReady = true; if (queued) { yt.loadVideoById(queued); queued = null; } } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadVideo(id) {
|
||||||
|
if (ytReady) { yt.loadVideoById(id); return; }
|
||||||
|
queued = id;
|
||||||
|
if (!yt && window.YT && YT.Player) createPlayer();
|
||||||
|
}
|
||||||
|
const ytDo = fn => { if (ytReady) yt[fn](); };
|
||||||
|
|
||||||
|
function parseYoutubeId(input) {
|
||||||
|
input = input.trim();
|
||||||
|
if (/^[\w-]{11}$/.test(input)) return input;
|
||||||
|
try {
|
||||||
|
const u = new URL(input);
|
||||||
|
const h = u.hostname.replace(/^www\.|^m\./, '');
|
||||||
|
if (h === 'youtu.be') return u.pathname.slice(1, 12);
|
||||||
|
if (h.endsWith('youtube.com')) {
|
||||||
|
if (u.searchParams.get('v')) return u.searchParams.get('v').slice(0, 11);
|
||||||
|
const m = u.pathname.match(/^\/(embed|shorts|live)\/([\w-]{11})/);
|
||||||
|
if (m) return m[2];
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Socket ---------- */
|
||||||
|
socket.on('connect', () => {
|
||||||
|
socket.emit('master:create', { roomId }, res => {
|
||||||
|
roomId = res.roomId;
|
||||||
|
localStorage.setItem('bt_room', roomId);
|
||||||
|
const joinUrl = `${location.origin}/buzzer.html?room=${roomId}`;
|
||||||
|
$('qr').src = '/qr.svg?text=' + encodeURIComponent(joinUrl);
|
||||||
|
$('joinUrl').textContent = joinUrl;
|
||||||
|
$('roomTag').textContent = 'SALLE ' + roomId;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
socket.on('state', s => { const prev = state.phase; state = s; render(prev); });
|
||||||
|
|
||||||
|
/* ---------- Rendu ---------- */
|
||||||
|
function sorted(players) {
|
||||||
|
return [...players].sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
function fillBoard(el, players, medals) {
|
||||||
|
el.textContent = '';
|
||||||
|
const icons = ['🥇', '🥈', '🥉'];
|
||||||
|
sorted(players).forEach((p, i) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
if (medals) {
|
||||||
|
const m = document.createElement('span'); m.textContent = icons[i] || (i + 1) + '.'; li.appendChild(m);
|
||||||
|
} else {
|
||||||
|
const d = document.createElement('span'); d.className = 'dot' + (p.connected ? '' : ' off'); li.appendChild(d);
|
||||||
|
}
|
||||||
|
const n = document.createElement('span'); n.className = 'name'; n.textContent = p.name;
|
||||||
|
const s = document.createElement('span'); s.className = 'score'; s.textContent = p.score;
|
||||||
|
li.append(n, s);
|
||||||
|
el.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function render(prev) {
|
||||||
|
const ph = state.phase;
|
||||||
|
$('lobby').hidden = ph !== 'lobby';
|
||||||
|
$('game').hidden = !['playing', 'buzzed', 'result'].includes(ph);
|
||||||
|
$('endScreen').hidden = ph !== 'ended';
|
||||||
|
$('side').hidden = ph === 'ended';
|
||||||
|
$('mainLayout').style.gridTemplateColumns = ph === 'ended' ? '1fr' : '';
|
||||||
|
$('empty').hidden = state.players.length > 0;
|
||||||
|
fillBoard($('board'), state.players, false);
|
||||||
|
fillBoard($('final'), state.players, true);
|
||||||
|
|
||||||
|
// Vidéo
|
||||||
|
if (ph !== prev) {
|
||||||
|
if (ph === 'playing' && prev === 'lobby' && nextVideo) { loadVideo(nextVideo); nextVideo = null; }
|
||||||
|
else if (ph === 'playing' && prev === 'buzzed') ytDo('playVideo');
|
||||||
|
else if (ph === 'buzzed') ytDo('pauseVideo');
|
||||||
|
else if (ph === 'lobby' || ph === 'ended') ytDo('stopVideo');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statut
|
||||||
|
const name = state.buzzer ? state.buzzer.name : '';
|
||||||
|
let text = '';
|
||||||
|
if (ph === 'playing') {
|
||||||
|
text = '🎵 Écoute… premier à buzzer !';
|
||||||
|
if (state.lockedOut.length) text += ` (${state.lockedOut.length} bloqué${state.lockedOut.length > 1 ? 's' : ''})`;
|
||||||
|
} else if (ph === 'buzzed') text = `🔔 ${name} propose …`;
|
||||||
|
else if (ph === 'result') text = `✅ ${name} gagne un point !`;
|
||||||
|
$('status').textContent = text;
|
||||||
|
$('judge').hidden = ph !== 'buzzed';
|
||||||
|
$('next').textContent = ph === 'result' ? 'Morceau suivant ▶' : 'Passer ce morceau ⏭';
|
||||||
|
updateMask();
|
||||||
|
}
|
||||||
|
function updateMask() {
|
||||||
|
$('mask').hidden = !($('hide').checked && state.phase !== 'result');
|
||||||
|
}
|
||||||
|
$('hide').addEventListener('change', updateMask);
|
||||||
|
|
||||||
|
/* ---------- Actions ---------- */
|
||||||
|
function submitUrl() {
|
||||||
|
const id = parseYoutubeId($('url').value);
|
||||||
|
if (!id) { $('urlError').textContent = "URL YouTube invalide."; return; }
|
||||||
|
$('urlError').textContent = '';
|
||||||
|
nextVideo = id;
|
||||||
|
$('url').value = '';
|
||||||
|
socket.emit('master:start');
|
||||||
|
}
|
||||||
|
$('go').addEventListener('click', submitUrl);
|
||||||
|
$('url').addEventListener('keydown', e => { if (e.key === 'Enter') submitUrl(); });
|
||||||
|
$('btnTrue').addEventListener('click', () => socket.emit('master:judge', true));
|
||||||
|
$('btnFalse').addEventListener('click', () => socket.emit('master:judge', false));
|
||||||
|
$('next').addEventListener('click', () => socket.emit('master:next'));
|
||||||
|
$('endBtn').addEventListener('click', () => socket.emit('master:end'));
|
||||||
|
$('endFromLobby').addEventListener('click', () => socket.emit('master:end'));
|
||||||
|
$('newGame').addEventListener('click', () => socket.emit('master:newgame'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
:root{--bg:#0f1220;--card:#181c30;--card2:#222744;--text:#f2f4ff;--muted:#8d93b5;--accent:#7c5cff;--ok:#22c55e;--ko:#ef4444;--r:16px}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
[hidden]{display:none!important}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;min-height:100vh}
|
||||||
|
h1,h2{margin:0}
|
||||||
|
h2{font-size:1.25rem;margin-bottom:12px}
|
||||||
|
.muted,.small{color:var(--muted)}
|
||||||
|
.small{font-size:.85rem;word-break:break-all}
|
||||||
|
.error{color:var(--ko);min-height:1.2em;margin:8px 0}
|
||||||
|
.card{background:var(--card);border-radius:var(--r);padding:24px}
|
||||||
|
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
||||||
|
input[type=url],input[type=text]{flex:1;min-width:200px;padding:14px 16px;border-radius:12px;border:2px solid var(--card2);background:var(--bg);color:var(--text);font-size:1rem}
|
||||||
|
input:focus{outline:none;border-color:var(--accent)}
|
||||||
|
button{font:inherit;font-weight:700;border:none;border-radius:12px;padding:14px 22px;cursor:pointer;color:var(--text);background:var(--card2)}
|
||||||
|
button:hover{filter:brightness(1.15)}
|
||||||
|
button.primary{background:var(--accent)}
|
||||||
|
button.ok{background:var(--ok);color:#052e12}
|
||||||
|
button.ko{background:var(--ko)}
|
||||||
|
button.ghost{background:transparent;border:2px solid var(--card2);color:var(--muted)}
|
||||||
|
label{color:var(--muted);display:flex;gap:6px;align-items:center}
|
||||||
|
|
||||||
|
/* Master */
|
||||||
|
.master{max-width:1200px;margin:0 auto;padding:20px}
|
||||||
|
.master header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}
|
||||||
|
.tag{background:var(--card);padding:6px 14px;border-radius:999px;color:var(--muted);font-weight:700;letter-spacing:.05em}
|
||||||
|
.layout{display:grid;grid-template-columns:1fr 300px;gap:20px;align-items:start}
|
||||||
|
@media(max-width:900px){.layout{grid-template-columns:1fr}}
|
||||||
|
.lobby-grid{display:grid;grid-template-columns:280px 1fr;gap:32px;align-items:center}
|
||||||
|
@media(max-width:700px){.lobby-grid{grid-template-columns:1fr}}
|
||||||
|
.qr img{width:100%;background:#fff;border-radius:12px;padding:8px;display:block}
|
||||||
|
.video{position:relative;aspect-ratio:16/9;background:#000;border-radius:12px;overflow:hidden}
|
||||||
|
.video iframe{position:absolute;inset:0;width:100%;height:100%;border:0}
|
||||||
|
.mask{position:absolute;inset:0;background:linear-gradient(135deg,#2b2250,#0f1220);display:flex;align-items:center;justify-content:center;font-size:6rem;z-index:2}
|
||||||
|
.status{font-size:1.6rem;font-weight:800;margin:20px 0 12px;min-height:2em}
|
||||||
|
.judge{display:flex;gap:16px;margin-bottom:16px}
|
||||||
|
.judge button{flex:1;font-size:1.6rem;padding:22px}
|
||||||
|
.board{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:8px}
|
||||||
|
.board li{display:flex;align-items:center;gap:10px;background:var(--card2);padding:10px 14px;border-radius:10px}
|
||||||
|
.board .name{flex:1;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.board .score{font-weight:800;font-size:1.1rem}
|
||||||
|
.dot{width:10px;height:10px;border-radius:50%;background:var(--ok)}
|
||||||
|
.dot.off{background:#555}
|
||||||
|
.board.final li{font-size:1.4rem;padding:16px 20px}
|
||||||
|
#endScreen{text-align:center}
|
||||||
|
#endScreen .board{margin:20px auto 28px;max-width:520px;text-align:left}
|
||||||
|
|
||||||
|
/* Buzzer */
|
||||||
|
.buzzer-page{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:24px;text-align:center;-webkit-user-select:none;user-select:none}
|
||||||
|
.buzzer-page .card{width:100%;max-width:420px}
|
||||||
|
.buzzer-page .row{flex-direction:column;align-items:stretch}
|
||||||
|
.me{display:flex;justify-content:space-between;width:100%;max-width:420px;font-weight:700}
|
||||||
|
#buzz{width:min(72vw,320px);aspect-ratio:1;border-radius:50%;font-size:2.6rem;font-weight:900;letter-spacing:.05em;color:#fff;background:radial-gradient(circle at 30% 30%,#ff7b7b,#d00000);box-shadow:0 12px 0 #7a0000,0 20px 40px rgba(0,0,0,.5);touch-action:manipulation;-webkit-tap-highlight-color:transparent;transition:transform .05s,box-shadow .05s}
|
||||||
|
#buzz:active:not(:disabled){transform:translateY(8px);box-shadow:0 4px 0 #7a0000}
|
||||||
|
#buzz:disabled{background:#3a3f5c;box-shadow:0 12px 0 #262a40;color:#8d93b5;font-size:1.3rem;cursor:not-allowed;padding:20px}
|
||||||
|
#msg{font-size:1.2rem;font-weight:700;min-height:1.5em}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
'use strict';
|
||||||
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const express = require('express');
|
||||||
|
const QRCode = require('qrcode');
|
||||||
|
const { Server } = require('socket.io');
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const io = new Server(server);
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
app.get('/healthz', (_req, res) => res.send('ok'));
|
||||||
|
app.get('/qr.svg', async (req, res) => {
|
||||||
|
const text = String(req.query.text || '').slice(0, 500);
|
||||||
|
if (!text) return res.status(400).end();
|
||||||
|
const svg = await QRCode.toString(text, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
|
||||||
|
res.type('image/svg+xml').send(svg);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- État des rooms (en mémoire) ---------- */
|
||||||
|
// phases : lobby | playing | buzzed | result | ended
|
||||||
|
const rooms = new Map();
|
||||||
|
const ROOM_RE = /^[A-Z0-9]{4,8}$/;
|
||||||
|
const TOKEN_RE = /^[A-Za-z0-9-]{8,40}$/;
|
||||||
|
const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
|
||||||
|
function newRoomId() {
|
||||||
|
let id;
|
||||||
|
do {
|
||||||
|
id = Array.from({ length: 5 }, () => CHARS[crypto.randomInt(CHARS.length)]).join('');
|
||||||
|
} while (rooms.has(id));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
function createRoom(id) {
|
||||||
|
const room = { id, phase: 'lobby', players: new Map(), buzzer: null, lockedOut: new Set(), lastActive: Date.now() };
|
||||||
|
rooms.set(id, room);
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
function publicState(room) {
|
||||||
|
return {
|
||||||
|
phase: room.phase,
|
||||||
|
buzzer: room.buzzer,
|
||||||
|
lockedOut: [...room.lockedOut],
|
||||||
|
players: [...room.players.values()].map(p => ({ id: p.id, name: p.name, score: p.score, connected: p.connected })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function broadcast(room) {
|
||||||
|
room.lastActive = Date.now();
|
||||||
|
io.to(room.id).emit('state', publicState(room));
|
||||||
|
}
|
||||||
|
function masterRoom(socket) {
|
||||||
|
if (socket.data.role !== 'master') return null;
|
||||||
|
return rooms.get(socket.data.roomId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', socket => {
|
||||||
|
/* ----- Master ----- */
|
||||||
|
socket.on('master:create', (data, ack) => {
|
||||||
|
ack = typeof ack === 'function' ? ack : () => {};
|
||||||
|
let id = String((data && data.roomId) || '').toUpperCase();
|
||||||
|
let room = ROOM_RE.test(id) ? rooms.get(id) : null;
|
||||||
|
if (!room) room = createRoom(ROOM_RE.test(id) ? id : newRoomId());
|
||||||
|
socket.data.role = 'master';
|
||||||
|
socket.data.roomId = room.id;
|
||||||
|
socket.join(room.id);
|
||||||
|
ack({ roomId: room.id });
|
||||||
|
broadcast(room);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('master:start', () => {
|
||||||
|
const room = masterRoom(socket);
|
||||||
|
if (!room || room.phase === 'ended') return;
|
||||||
|
room.phase = 'playing';
|
||||||
|
room.buzzer = null;
|
||||||
|
room.lockedOut.clear();
|
||||||
|
broadcast(room);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('master:judge', correct => {
|
||||||
|
const room = masterRoom(socket);
|
||||||
|
if (!room || room.phase !== 'buzzed' || !room.buzzer) return;
|
||||||
|
const player = [...room.players.values()].find(p => p.id === room.buzzer.id);
|
||||||
|
if (correct) {
|
||||||
|
if (player) player.score += 1;
|
||||||
|
room.phase = 'result'; // le buzzer reste affiché comme gagnant
|
||||||
|
} else {
|
||||||
|
room.lockedOut.add(room.buzzer.id);
|
||||||
|
room.buzzer = null;
|
||||||
|
room.phase = 'playing'; // la vidéo reprend, ce joueur est bloqué pour ce morceau
|
||||||
|
}
|
||||||
|
broadcast(room);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('master:next', () => {
|
||||||
|
const room = masterRoom(socket);
|
||||||
|
if (!room || room.phase === 'ended') return;
|
||||||
|
room.phase = 'lobby';
|
||||||
|
room.buzzer = null;
|
||||||
|
room.lockedOut.clear();
|
||||||
|
broadcast(room);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('master:end', () => {
|
||||||
|
const room = masterRoom(socket);
|
||||||
|
if (!room) return;
|
||||||
|
room.phase = 'ended';
|
||||||
|
room.buzzer = null;
|
||||||
|
broadcast(room);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('master:newgame', () => {
|
||||||
|
const room = masterRoom(socket);
|
||||||
|
if (!room) return;
|
||||||
|
room.players.forEach(p => { p.score = 0; });
|
||||||
|
room.phase = 'lobby';
|
||||||
|
room.buzzer = null;
|
||||||
|
room.lockedOut.clear();
|
||||||
|
broadcast(room);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ----- Joueurs ----- */
|
||||||
|
socket.on('player:join', (data, ack) => {
|
||||||
|
ack = typeof ack === 'function' ? ack : () => {};
|
||||||
|
data = data || {};
|
||||||
|
const room = rooms.get(String(data.roomId || '').toUpperCase());
|
||||||
|
if (!room) return ack({ error: 'no_room' });
|
||||||
|
|
||||||
|
const token = TOKEN_RE.test(data.token || '') ? data.token : null;
|
||||||
|
let player = token ? room.players.get(token) : null;
|
||||||
|
if (!player) {
|
||||||
|
const name = String(data.name || '').trim().replace(/\s+/g, ' ').slice(0, 20);
|
||||||
|
if (!name) return ack({ error: 'no_name' });
|
||||||
|
const taken = [...room.players.values()].some(p => p.name.toLowerCase() === name.toLowerCase());
|
||||||
|
if (taken) return ack({ error: 'name_taken' });
|
||||||
|
const t = token || crypto.randomUUID();
|
||||||
|
player = { id: crypto.randomBytes(4).toString('hex'), token: t, name, score: 0, connected: true };
|
||||||
|
room.players.set(t, player);
|
||||||
|
}
|
||||||
|
player.connected = true;
|
||||||
|
socket.data.role = 'player';
|
||||||
|
socket.data.roomId = room.id;
|
||||||
|
socket.data.token = player.token;
|
||||||
|
socket.join(room.id);
|
||||||
|
ack({ token: player.token, id: player.id, name: player.name });
|
||||||
|
broadcast(room);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('player:buzz', () => {
|
||||||
|
if (socket.data.role !== 'player') return;
|
||||||
|
const room = rooms.get(socket.data.roomId);
|
||||||
|
const player = room && room.players.get(socket.data.token);
|
||||||
|
if (!room || !player) return;
|
||||||
|
if (room.phase !== 'playing' || room.lockedOut.has(player.id)) return;
|
||||||
|
room.phase = 'buzzed'; // le premier message reçu gagne
|
||||||
|
room.buzzer = { id: player.id, name: player.name };
|
||||||
|
broadcast(room);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
if (socket.data.role !== 'player') return;
|
||||||
|
const room = rooms.get(socket.data.roomId);
|
||||||
|
const player = room && room.players.get(socket.data.token);
|
||||||
|
if (player) {
|
||||||
|
player.connected = false;
|
||||||
|
broadcast(room);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nettoyage des rooms inactives (12 h)
|
||||||
|
setInterval(() => {
|
||||||
|
const limit = Date.now() - 12 * 3600 * 1000;
|
||||||
|
for (const [id, room] of rooms) if (room.lastActive < limit) rooms.delete(id);
|
||||||
|
}, 30 * 60 * 1000).unref();
|
||||||
|
|
||||||
|
server.listen(PORT, () => console.log(`Blind test sur le port ${PORT}`));
|
||||||
Reference in New Issue
Block a user