Тетрис- и Space Invaders-подобные аркады на чистом canvas/JS, без мультиплеера и без сервера — в отличие от ./tanks это классические одиночные игры. Названия сайта — generic (не «Тетрис»/«Space Invaders»), та же логика, что и с переименованием ./battlecity в ./tanks: обе франшизы — активно защищаемые товарные знаки (The Tetris Company, Taito). ./blocks — 10x20 поле, 7 фигур, вращение с простым wall-kick, ускорение по уровням, next-piece превью, hard drop. ./invaders — волны врагов, стрельба игрока и врагов, жизни, ускорение с каждой волной. Обе — клавиатура + сенсорные кнопки, RU/EN (не сбрасывает партию при переключении языка — в отличие от бага, который чинили в ./tanks). Кросс-ссылки между всеми тремя играми на каждой игровой странице, подвал whoami обновлён списком всех трёх.
232 lines
7.2 KiB
JavaScript
232 lines
7.2 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: 'выйти в лобби',
|
|
crosslinks: 'ещё игры:',
|
|
},
|
|
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',
|
|
crosslinks: 'more games:',
|
|
},
|
|
};
|
|
|
|
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) => ({
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
}[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() {
|
|
const crosslinksLabel = document.getElementById('game-crosslinks-label');
|
|
if (crosslinksLabel) crosslinksLabel.textContent = t().crosslinks;
|
|
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;
|
|
}
|