Files
cactoz.su/frontend/js/game/lobby.js
T
cacto d4655bda2b Правки по аудиту сайта: og-теги, честный whoami, prerender, переименование игры
- og:title/description/image + canonical + twitter:card в index.html,
  нарисован assets/og.png (1200x630, тема сайта). CSP не трогали — картинка
  того же происхождения.
- Переписан текст «обо мне» и формулировка про текущую работу в
  Уралэнергоаква (убраны двусмысленности "неофициально возглавлял" и
  "продолжаю искать"), Go убран из title/skills-тега как отдельная
  "изучаю" пометка — теперь просто в стеке, раз есть реальный Go-код в
  home_automatization.
- resume.js/projects.js больше не перерисовывают контент через JS при
  языке по умолчанию (ru) — index.html/projects.html содержат готовый
  серверный HTML, JS перерисовывает только при переключении на EN. Раньше
  весь текст резюме подгружался пустым в #resume-root, curl без JS ничего
  не видел.
- Добавлены кнопка «скачать резюме (PDF)» и ссылка на git.cactoz.su в
  первый экран резюме.
- home_automatization на /projects честно помечен «в разработке» вместо
  описания как готового продукта.
- Игра переименована из BattleCity (товарный знак Namco) в ./tanks,
  убрана из основного меню во все страницы — ссылка теперь только в
  подвале страницы whoami.
2026-08-10 20:40:03 +05:00

228 lines
7.0 KiB
JavaScript

import { Network, ServerMessage } from './network.js';
import { ClientMessage } from './protocol.js';
import { startBattle } from './battle.js';
import { unlockAudio } from './audio.js';
import { getLang } from '../i18n.js';
const container = document.getElementById('lobby');
// Статичные подписи UI лобби переводим; данные из сервера (ники, room id,
// текст ошибок) остаются как есть — они генерируются на сервере только на русском.
const STR = {
ru: {
connecting: 'подключение...',
join: 'войти',
createCoop: 'создать комнату (coop)',
createPvp: 'создать комнату (pvp)',
refresh: 'обновить список',
empty: 'пока пусто — создай комнату',
ready: 'готов',
readySuffix: ' — готов',
waitingForSecond: 'жду второго игрока (1/2) — открой комнату во второй вкладке/другим человеком',
soloHint: 'можно начать соло, второй игрок сможет подключиться позже',
room: 'комната',
mode: 'режим:',
leave: 'выйти в лобби',
},
en: {
connecting: 'connecting...',
join: 'join',
createCoop: 'create room (coop)',
createPvp: 'create room (pvp)',
refresh: 'refresh list',
empty: 'nothing here yet — create a room',
ready: 'ready',
readySuffix: ' — ready',
waitingForSecond: 'waiting for a second player (1/2) — open the room in another tab or have someone else join',
soloHint: 'you can start solo, a second player can join later',
room: 'room',
mode: 'mode:',
leave: 'leave to lobby',
},
};
function t() {
return STR[getLang()];
}
let net;
let myPlayerId = null;
let rooms = [];
let currentRoom = null;
let errorMessage = '';
let connected = false;
let nickname = 'cacto-' + Math.floor(Math.random() * 1000);
// Ники приходят от других игроков (сервер их только режет по длине, не
// экранирует) и попадают сюда через innerHTML — без экранирования это XSS.
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (c) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[c]));
}
function roomRow(room) {
const full = room.players >= room.max_players;
return `
<li class="room-list__item">
<span class="room-list__name">${room.name}</span>
<span class="tag">${room.mode}</span>
<span class="room-list__count">${room.players}/${room.max_players}</span>
<span class="room-list__state">${room.state}</span>
<button data-action="join" data-room-id="${room.id}" ${full || room.state !== 'waiting' || !connected ? 'disabled' : ''}>${t().join}</button>
</li>`;
}
function errorBanner() {
return errorMessage ? `<p class="lobby-error">! ${errorMessage}</p>` : '';
}
function renderLobbyView() {
const dis = connected ? '' : 'disabled';
container.innerHTML = `
<div class="lobby-panel">
${errorBanner()}
${connected ? '' : `<p class="lobby-error">${t().connecting}</p>`}
<div class="lobby-panel__row">
<label class="prompt-inline">nickname</label>
<input id="nickname-input" type="text" value="${escapeHtml(nickname)}" maxlength="20" ${dis} />
</div>
<div class="lobby-panel__row">
<button data-action="create" data-mode="coop" ${dis}>${t().createCoop}</button>
<button data-action="create" data-mode="pvp" ${dis}>${t().createPvp}</button>
<button data-action="refresh" ${dis}>${t().refresh}</button>
</div>
<ul class="room-list">
${rooms.length === 0 ? `<li class="room-list__empty">${t().empty}</li>` : rooms.map(roomRow).join('')}
</ul>
</div>`;
}
function renderRoomView() {
const full = currentRoom.players.length >= 2;
let statusLine = '';
let readyBtn = `<button data-action="ready">${t().ready}</button>`;
if (currentRoom.mode === 'pvp' && !full) {
statusLine = `<p class="lobby-error">${t().waitingForSecond}</p>`;
readyBtn = `<button data-action="ready" disabled>${t().ready}</button>`;
} else if (currentRoom.mode === 'coop' && !full) {
statusLine = `<p class="lobby-error">${t().soloHint}</p>`;
}
container.innerHTML = `
<div class="lobby-panel">
${errorBanner()}
<h3 class="prompt">${t().room} ${currentRoom.name}</h3>
<p>${t().mode} <span class="tag">${currentRoom.mode}</span></p>
<ul class="room-players">
${currentRoom.players.map((p) => `<li>${escapeHtml(p.nickname)}${p.ready ? t().readySuffix : ''}</li>`).join('')}
</ul>
${statusLine}
${readyBtn}
<button data-action="leave">${t().leave}</button>
</div>`;
}
function render() {
if (currentRoom) {
renderRoomView();
} else {
renderLobbyView();
}
}
// Вызывается из battle.js по кнопкам экрана победы/поражения.
export function returnToLobbyContainer() {
document.getElementById('tanks-canvas-container').style.display = 'none';
container.style.display = '';
render();
}
export function leaveRoomAndReturn() {
currentRoom = null;
net.leaveRoom();
returnToLobbyContainer();
}
container?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
errorMessage = '';
switch (btn.dataset.action) {
case 'create':
net.createRoom(btn.dataset.mode);
break;
case 'join':
net.joinRoom(btn.dataset.roomId);
break;
case 'ready':
unlockAudio();
net.send(ClientMessage.GAME_READY);
break;
case 'leave':
currentRoom = null;
net.leaveRoom();
render();
break;
case 'refresh':
net.listRooms();
break;
}
});
container?.addEventListener('change', (e) => {
if (e.target.id === 'nickname-input') {
nickname = e.target.value.trim() || nickname;
}
});
export function initLobby() {
net = new Network();
net.on('_open', () => net.hello(nickname));
net.on(ServerMessage.HELLO_ACK, (payload) => {
connected = true;
myPlayerId = payload.player_id;
net.listRooms();
});
net.on('_close', () => {
connected = false;
render();
});
net.on(ServerMessage.LOBBY_ROOMS, (payload) => {
rooms = payload.rooms;
render();
});
net.on(ServerMessage.LOBBY_ROOM_JOINED, (payload) => {
currentRoom = payload;
render();
});
net.on(ServerMessage.LOBBY_ROOM_UPDATED, (payload) => {
if (currentRoom) {
currentRoom.players = payload.players;
render();
}
});
net.on(ServerMessage.GAME_START, (payload) => {
startBattle(net, payload, myPlayerId);
});
net.on(ServerMessage.ERROR, (payload) => {
console.error('[gameserver] error:', payload);
errorMessage = payload.message;
render();
});
window.addEventListener('cacto:langchange', render);
render();
return net;
}