diff --git a/data/app.js b/data/app.js index b1f5859..901e795 100644 --- a/data/app.js +++ b/data/app.js @@ -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 })); diff --git a/data/index.html b/data/index.html index 8bed053..ffd1367 100644 --- a/data/index.html +++ b/data/index.html @@ -50,7 +50,7 @@
- +