feat(ui): implement responsive player browser experience
This commit is contained in:
+57
-121
@@ -2,129 +2,65 @@
|
||||
<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>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#090711">
|
||||
<title>maze32 — побег из кустов</title>
|
||||
<link rel="stylesheet" href="/player.css">
|
||||
</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 id="app">
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/" aria-label="maze32, на главную">MAZE<span>32</span></a>
|
||||
<div class="connection-pill" id="connection" data-state="connecting" role="status"><span class="signal" aria-hidden="true"></span><span id="connection-text">Ловим сигнал ESP…</span></div>
|
||||
<button class="icon-button" id="details-toggle" type="button" aria-controls="details-panel" aria-expanded="false"><svg aria-hidden="true"><use href="/icons.svg#signal"></use></svg><span class="sr-only">Открыть сведения о соединении</span></button>
|
||||
</header>
|
||||
<aside class="details-panel" id="details-panel" hidden><strong>Связь с коробочкой</strong><span id="details-text">Подключение ещё не готово.</span><a href="/provision">Настроить Wi‑Fi</a></aside>
|
||||
|
||||
<section class="screen welcome-screen" id="welcome-screen" aria-labelledby="welcome-title">
|
||||
<div class="hero-art" aria-hidden="true"><span class="pixel-bush bush-one"></span><span class="pixel-bush bush-two"></span><svg><use href="/icons.svg#maze"></use></svg></div>
|
||||
<div class="panel welcome-panel">
|
||||
<p class="eyebrow">Локальная гонка на два носа</p>
|
||||
<h1 id="welcome-title">Кусты уже<br><span>потеряли выход</span></h1>
|
||||
<p>Выбирай позывной. Сервер запомнит всё важное, а браузеру доверит только красивые пиксели.</p>
|
||||
<label for="player-name">Позывной игрока</label>
|
||||
<input id="player-name" maxlength="32" autocomplete="nickname" placeholder="Например, Байт-Беглец">
|
||||
<div class="button-row"><button class="button secondary" id="suggest-name" type="button">Подкинуть имя</button><button class="button primary" id="join-player" type="button">Занять слот</button></div>
|
||||
<button class="text-button" id="join-observer" type="button">Смотреть и не трогать</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="screen lobby-screen" id="lobby-screen" hidden aria-labelledby="lobby-title">
|
||||
<div class="section-heading"><div><p class="eyebrow">Комната ожидания</p><h1 id="lobby-title">Сбор заблудившихся</h1></div><span class="phase-chip" id="lobby-phase">Ждём игроков</span></div>
|
||||
<div class="lobby-grid">
|
||||
<article class="panel roster-panel">
|
||||
<h2>Игроки</h2>
|
||||
<div class="player-card player-one" id="player-1"><span class="avatar">P1</span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
|
||||
<div class="player-card player-two" id="player-2"><span class="avatar">P2</span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
|
||||
</article>
|
||||
<article class="panel observer-panel"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><h2>Зрительный зал</h2><p><strong id="observer-count">0</strong> / 4 эксперта по чужим ошибкам.</p></article>
|
||||
<article class="panel launch-panel"><h2>Большая красная кнопка</h2><p id="launch-help">Старт доступен Игроку 1, когда оба слота на месте.</p><button class="button primary launch-button" id="start-match" type="button" disabled>Начать 3—2—1</button></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="screen match-screen" id="match-screen" hidden aria-label="Игровой экран">
|
||||
<div class="player-status" id="player-status">
|
||||
<div class="identity" id="identity"><span class="avatar" id="self-slot">P?</span><span><strong id="self-name">Неизвестный герой</strong><small id="match-state">Лабиринт думает</small></span></div>
|
||||
<div class="stat" id="inventory-stat"><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><span><small>Ключ</small><strong id="inventory">Пусто</strong></span></div>
|
||||
<div class="stat" id="vision-stat"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><span><small>Суперзрение</small><strong id="vision">Нет</strong></span></div>
|
||||
<div class="stat compass-stat" id="compass-stat"><svg id="compass-arrow" aria-hidden="true"><use href="/icons.svg#arrow"></use></svg><span><small>Компас</small><strong id="compass">Молчит</strong></span></div>
|
||||
</div>
|
||||
<div class="map-shell" id="map-shell" tabindex="0" aria-label="Лабиринт. Используйте стрелки, WASD или свайпы для движения">
|
||||
<div class="unknown-message" id="map-placeholder"><svg aria-hidden="true"><use href="/icons.svg#radar"></use></svg><span>Ждём разрешённый сервером кусочек карты…</span></div>
|
||||
<div class="maze" id="maze" role="img" aria-label="Видимая часть лабиринта"></div>
|
||||
<div class="wall-bump" id="wall-bump" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<button class="drop-key" id="drop-key" type="button" aria-label="Сбросить ключ" disabled><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><strong>OK</strong><span>бросить</span></button>
|
||||
<nav class="dpad" aria-label="Управление движением"><button type="button" data-direction="north" aria-label="Двигаться вверх">▲</button><button type="button" data-direction="west" aria-label="Двигаться влево">◀</button><span aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо">▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз">▼</button></nav>
|
||||
</section>
|
||||
<div class="notice" id="notice" role="status" aria-live="polite">Сервер решает всё. Мы отвечаем за драматичное мерцание.</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>
|
||||
|
||||
<div class="modal" id="modal" hidden role="dialog" aria-modal="true" aria-labelledby="modal-title"><div class="modal-card"><svg id="modal-icon" aria-hidden="true"><use href="/icons.svg#hourglass"></use></svg><p class="eyebrow" id="modal-kicker">Серверное объявление</p><h2 id="modal-title">Приготовились!</h2><div class="countdown" id="countdown" hidden>3</div><p id="modal-text">Один сервер — один честный момент.</p><button class="button primary" id="modal-action" type="button" hidden>Вернуться в лобби</button></div></div>
|
||||
<script src="/player.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user