feat: add active-game screen and firing interactions

This commit is contained in:
2026-08-30 00:46:39 +03:00
parent a12eac6b5b
commit 4d9d0db02d
5 changed files with 163 additions and 11 deletions
+58 -8
View File
@@ -24,6 +24,8 @@
let pollTimer;
let heartbeatTimer;
let lobbyInfoTimer;
let firePending = false;
let targetActivator;
let retryIndex = 0;
function showScreen(name) {
@@ -127,8 +129,40 @@
function ownIndex() { return role === 'player2' ? 1 : 0; }
function opponentIndex() { return ownIndex() ^ 1; }
function canShoot() { return state?.phase === 'in_progress' && state.turn === role && isPlayer(); }
function canFireTarget(target) {
return !firePending && canShoot() && Number.isInteger(target?.x) && Number.isInteger(target?.y) &&
target.x >= 0 && target.x < 10 && target.y >= 0 && target.y < 10 &&
state.boards[opponentIndex()][target.y * 10 + target.x] === '0';
}
function playerLabel(index) { return index === 1 && state?.mode === 'bot' ? 'ESP32' : `Игрок ${index + 1}`; }
async function fireTarget(target) {
if (!canFireTarget(target)) return false;
firePending = true;
selectedTarget = undefined;
renderGame();
try {
await post('/api/game/shot', { token, gameId: state.gameId, ...target });
return true;
} catch (_) {
return false;
} finally {
firePending = false;
if (state?.phase === 'in_progress') renderGame();
}
}
function activateTarget(target, keyboard = false) {
if (!targetActivator) {
targetActivator = globalThis.BattleshipTargetInteraction.createTargetActivator({
canFire: canFireTarget,
select: nextTarget => { selectedTarget = nextTarget; renderGame(); },
fire: fireTarget,
});
}
return keyboard ? targetActivator.keyboard(target) : targetActivator.tap(target);
}
function cellInfo(value) {
if (value === '1') return ['ship', 'Корабль'];
if (value === '2') return ['miss', 'Промах'];
@@ -156,12 +190,26 @@
const value = state.boards[index][y * 10 + x];
const [kind, label] = cellInfo(value);
const cell = document.createElement('button');
cell.type = 'button'; cell.className = `cell ${kind}`.trim(); cell.disabled = !targetable || value !== '0';
cell.type = 'button'; cell.className = `cell ${kind}`.trim(); cell.disabled = !targetable || value !== '0' || firePending;
cell.setAttribute('aria-label', `${columns[x]}${y + 1}: ${label}`);
if (targetable && value === '0') {
cell.classList.add('target');
if (selectedTarget?.x === x && selectedTarget?.y === y) cell.classList.add('selected');
cell.addEventListener('click', () => { selectedTarget = { x, y }; renderGame(); });
const target = { x, y };
cell.addEventListener('pointerup', event => {
if (event.button !== 0) return;
event.preventDefault();
activateTarget(target);
});
cell.addEventListener('click', event => {
if (event.detail === 0) activateTarget(target, true);
});
cell.addEventListener('dblclick', event => {
event.preventDefault();
if (targetActivator) targetActivator.doubleActivate(target);
});
} else if (value === '0') {
cell.classList.add(index === ownIndex() && isPlayer() ? 'own-water' : 'unavailable');
}
grid.append(cell);
}
@@ -225,14 +273,16 @@
if (!state) return;
stopLobbyInfoPolling();
showScreen('game');
ui.turn.textContent = state.turn === role ? 'Ваш ход. Выберите клетку соперника.' : `Ходит ${playerLabel(state.turn === 'player2' ? 1 : 0)}.`;
if (canShoot() && !selectedTarget) activeBoard = 'opponent';
ui.turn.textContent = canShoot() ? 'Ваш ход' : `Ходит ${playerLabel(state.turn === 'player2' ? 1 : 0)}`;
ui.wins.textContent = `Победы: ${state.wins[0]} : ${state.wins[1]}`;
renderBoards(ui.boards);
const available = canShoot();
ui.shotControls.hidden = !isPlayer();
ui.target.textContent = available ? (selectedTarget ? `Цель: ${columns[selectedTarget.x]}${selectedTarget.y + 1}. Подтвердите выстрел.` : 'Выберите клетку на поле соперника.') : 'Ожидайте своего хода.';
ui.fire.disabled = !available || !selectedTarget;
ui.cancel.disabled = !selectedTarget;
ui.target.textContent = firePending ? 'Выстрел отправляется…' : available ? (selectedTarget ? `Цель: ${columns[selectedTarget.x]}${selectedTarget.y + 1}` : 'Выберите клетку на поле соперника.') : 'Ожидайте своего хода.';
ui.fire.disabled = !selectedTarget || !canFireTarget(selectedTarget);
ui.fire.textContent = firePending ? 'Выстрел…' : 'Огонь';
ui.cancel.disabled = !selectedTarget || firePending;
ui.abort.hidden = !(role === 'player1' && state.mode === 'human' && state.phase === 'in_progress');
}
@@ -292,7 +342,7 @@
const joined = await request('/api/session/join', { method: 'POST', headers: headers(), body: JSON.stringify({ name, requestedRole }) });
token = joined.token; role = joined.role;
localStorage.setItem(storage.token, token); localStorage.setItem(storage.name, name);
notify('Подключено.');
notify(''); setConnection('Подключено', 'online');
await pollState();
connectSocket();
} catch (error) { notify(error.message, true); }
@@ -329,7 +379,7 @@
el('join-spectator').addEventListener('click', () => join('spectator'));
document.querySelectorAll('.mode-button').forEach(button => button.addEventListener('click', () => post('/api/game/config', { token, gameId: state.gameId, mode: button.dataset.mode })));
ui.start.addEventListener('click', () => post('/api/game/start', { token, gameId: state.gameId }));
ui.fire.addEventListener('click', async () => { if (!selectedTarget) return; await post('/api/game/shot', { token, gameId: state.gameId, ...selectedTarget }); selectedTarget = undefined; });
ui.fire.addEventListener('click', () => { if (selectedTarget) fireTarget(selectedTarget); });
ui.cancel.addEventListener('click', () => { selectedTarget = undefined; renderGame(); });
ui.abort.addEventListener('click', () => post('/api/game/abort', { token, gameId: state.gameId }));
ui.rematch.addEventListener('click', () => post('/api/game/rematch', { token, gameId: state.gameId }));