131 lines
9.2 KiB
HTML
131 lines
9.2 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>maze32 — лобби</title>
|
||
<style>
|
||
:root { color-scheme: dark; font-family: ui-monospace, "Cascadia Mono", monospace; --ink:#f5edff; --bg:#090711; --panel:#1d1230; --edge:#65488a; --aqua:#42ead2; --p1:#59a9ff; --p2:#ff73bf; --ok:#72e28b; --warn:#ffd75a; --bad:#ff657a; }
|
||
* { box-sizing: border-box; }
|
||
body { min-height:100vh; margin:0; padding:max(1rem,env(safe-area-inset-top)) max(1rem,env(safe-area-inset-right)) max(1rem,env(safe-area-inset-bottom)) max(1rem,env(safe-area-inset-left)); background:radial-gradient(circle at top,#24113a,var(--bg) 55%); color:var(--ink); }
|
||
main { width:min(70rem,100%); margin:auto; }
|
||
header { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:1rem; }
|
||
h1,h2 { margin:.2rem 0; text-transform:uppercase; letter-spacing:.09em; text-shadow:2px 2px #000; }
|
||
h1 { color:var(--aqua); }
|
||
.status { padding:.45rem .7rem; border:1px solid var(--edge); background:#10091d; }
|
||
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(17rem,1fr)); gap:1rem; }
|
||
section { min-height:13rem; padding:1rem; border:2px solid var(--edge); box-shadow:5px 5px 0 #050307; background:var(--panel); }
|
||
.player { display:grid; grid-template-columns:2.2rem 1fr auto; gap:.7rem; align-items:center; margin:.75rem 0; padding:.75rem; border:1px solid #493563; background:#130c20; }
|
||
.p1 { border-left:5px solid var(--p1); } .p2 { border-left:5px solid var(--p2); }
|
||
.lamp { color:var(--bad); } .online .lamp { color:var(--ok); }
|
||
label { display:block; margin:.8rem 0 .3rem; }
|
||
input,button { min-height:2.8rem; border:2px solid var(--aqua); border-radius:0; background:#0c1520; color:var(--ink); font:inherit; }
|
||
input { width:100%; padding:.55rem; }
|
||
button { padding:.5rem .8rem; cursor:pointer; box-shadow:3px 3px 0 #020506; }
|
||
button:hover:not(:disabled) { background:#123a40; } button:active:not(:disabled) { transform:translate(2px,2px); box-shadow:1px 1px 0 #020506; }
|
||
button:disabled { opacity:.45; cursor:not-allowed; }
|
||
.actions { display:flex; flex-wrap:wrap; gap:.6rem; margin-top:.7rem; }
|
||
#notice { min-height:2.8rem; margin-top:1rem; padding:.7rem; border-left:4px solid var(--warn); background:#17101f; }
|
||
#notice.error { border-color:var(--bad); color:#ffc0c8; }
|
||
#notice.good { border-color:var(--ok); color:#b5ffc4; }
|
||
dialog { width:min(28rem,calc(100% - 2rem)); border:3px solid var(--aqua); background:#0b0712ef; color:var(--ink); text-align:center; }
|
||
dialog::backdrop { background:#030205d9; }
|
||
#count { margin:1rem; color:var(--warn); font-size:clamp(5rem,30vw,12rem); animation:pulse .65s steps(2,end) infinite; }
|
||
.observer-warning { display:none; color:var(--warn); }
|
||
body.observer-mode .observer-warning { display:block; }
|
||
@media (min-width:1024px) and (min-height:768px) { body.observer-mode .observer-warning { display:none; } }
|
||
@keyframes pulse { 50% { transform:scale(1.08); } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<header><h1>maze32</h1><span class="status" id="connection">Подключаем провода к воздуху…</span></header>
|
||
<div class="grid">
|
||
<section>
|
||
<h2>Игроки</h2>
|
||
<div class="player p1" id="p1"><b>P1</b><span class="name">Свободно</span><span class="lamp">●</span></div>
|
||
<div class="player p2" id="p2"><b>P2</b><span class="name">Свободно</span><span class="lamp">●</span></div>
|
||
</section>
|
||
<section>
|
||
<h2>Наблюдатели</h2>
|
||
<p><strong id="observers">0</strong> / 4 смотрят очень внимательно.</p>
|
||
<p class="observer-warning">Для режима наблюдателя нужен экран не меньше 1024 × 768.</p>
|
||
<button id="observe">Смотреть лабиринт</button>
|
||
</section>
|
||
<section>
|
||
<h2>Пульт запуска</h2>
|
||
<div id="join">
|
||
<label for="name">Позывной игрока</label>
|
||
<input id="name" maxlength="32" autocomplete="nickname" placeholder="Например, Байт-Беглец">
|
||
<div class="actions"><button id="suggest">Подкинуть имя</button><button id="play">Занять слот</button></div>
|
||
</div>
|
||
<div class="actions"><button id="start" disabled>Начать 3-2-1</button><a href="/provision"><button>Wi‑Fi</button></a></div>
|
||
</section>
|
||
</div>
|
||
<div id="notice" role="status">Сервер решает всё. Браузер только красиво моргает.</div>
|
||
</main>
|
||
<dialog id="overlay"><h2 id="overlay-title">Приготовились!</h2><div id="count">3</div><p id="overlay-text">Сервер синхронизирует старт.</p></dialog>
|
||
<script>
|
||
(() => {
|
||
const $ = id => document.getElementById(id);
|
||
const state = { socket:null, clientSeq:0, serverSeq:0, session:null, lobby:null, reconnect:null };
|
||
const tokenKey = 'maze32.session.v1';
|
||
const notice = (text, kind='') => { $('notice').textContent=text; $('notice').className=kind; };
|
||
const send = (type,payload={},requestId=type) => {
|
||
if (!state.socket || state.socket.readyState !== WebSocket.OPEN) return;
|
||
state.socket.send(JSON.stringify({v:1,type,seq:++state.clientSeq,requestId,payload}));
|
||
};
|
||
const render = () => {
|
||
if (!state.lobby) return;
|
||
state.lobby.players.forEach(player => {
|
||
const row = $('p'+player.slot);
|
||
row.querySelector('.name').textContent = player.occupied ? player.name : 'Свободно';
|
||
row.classList.toggle('online',player.connected);
|
||
});
|
||
$('observers').textContent=state.lobby.observers;
|
||
$('join').hidden=!!state.session;
|
||
$('observe').hidden=!!state.session;
|
||
const owner=state.session?.role==='player' && state.session.slot===1;
|
||
$('start').disabled=!(owner && state.lobby.ready && state.lobby.phase==='waiting');
|
||
document.body.classList.toggle('observer-mode',state.session?.role==='observer');
|
||
};
|
||
const countdown = () => {
|
||
const dialog=$('overlay'); $('overlay-title').textContent='Приготовились!'; $('overlay-text').textContent='Один сервер — один момент старта.';
|
||
if (!dialog.open) dialog.showModal();
|
||
let value=3; $('count').textContent=value;
|
||
const timer=setInterval(() => { value--; if (value>0) $('count').textContent=value; else clearInterval(timer); },1000);
|
||
};
|
||
const handle = event => {
|
||
let message; try { message=JSON.parse(event.data); } catch { notice('Сервер прислал нечитаемый пакет.','error'); return; }
|
||
if (message.v!==1 || !Number.isInteger(message.seq) || message.seq<=state.serverSeq) return;
|
||
state.serverSeq=message.seq;
|
||
if (message.type==='session') {
|
||
state.session=message.payload; localStorage.setItem(tokenKey,message.payload.token);
|
||
notice(message.payload.role==='player' ? `Слот P${message.payload.slot} закреплён. Токен спрятан в этом браузере.` : 'Вы в зрительном зале. Руками не трогаем!','good');
|
||
} else if (message.type==='lobby') { state.lobby=message.payload; render();
|
||
} else if (message.type==='name_suggestion') { $('name').value=message.payload.name;
|
||
} else if (message.type==='countdown') { countdown();
|
||
} else if (message.type==='active') { $('count').textContent='GO'; $('overlay-title').textContent='Старт!'; $('overlay-text').textContent='Лобби активировано. Лабиринт появится в следующем этапе.'; setTimeout(()=>$('overlay').close(),1200);
|
||
} else if (message.type==='error') {
|
||
if (message.payload.code==='invalid_token') { localStorage.removeItem(tokenKey); state.session=null; render(); }
|
||
notice(message.payload.message || 'Команда отклонена сервером.','error');
|
||
}
|
||
};
|
||
const connect = () => {
|
||
clearTimeout(state.reconnect); state.clientSeq=0; state.serverSeq=0;
|
||
const scheme=location.protocol==='https:'?'wss':'ws'; state.socket=new WebSocket(`${scheme}://${location.host}/ws`);
|
||
state.socket.onopen=() => { $('connection').textContent='Связь с ESP: есть'; const token=localStorage.getItem(tokenKey); if(token) send('restore',{token},'refresh'); };
|
||
state.socket.onmessage=handle;
|
||
state.socket.onclose=() => { $('connection').textContent='Связь потеряна, переподключаемся…'; state.reconnect=setTimeout(connect,1000); };
|
||
state.socket.onerror=() => notice('WebSocket споткнулся. Пробуем снова…','error');
|
||
};
|
||
$('suggest').onclick=()=>send('suggest_name',{},'suggest');
|
||
$('play').onclick=()=>send('join',{role:'player',name:$('name').value},'join-player');
|
||
$('observe').onclick=()=>send('join',{role:'observer'},'join-observer');
|
||
$('start').onclick=()=>send('start',{},'start');
|
||
connect();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|