feat: add active-game screen and firing interactions
This commit is contained in:
+58
-8
@@ -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 }));
|
||||
|
||||
+2
-1
@@ -50,7 +50,7 @@
|
||||
<div id="board-tabs" class="board-tabs" role="tablist" aria-label="Выбор поля"></div>
|
||||
<div id="boards" class="boards"></div>
|
||||
<div id="shot-controls" class="shot-controls" hidden><p id="target-status" class="muted">Выберите клетку на поле соперника.</p><button id="fire-button" type="button" disabled>Огонь</button><button id="cancel-target" type="button" class="secondary" disabled>Отменить</button></div>
|
||||
<button id="abort-game" type="button" class="text-button" hidden>Отменить партию</button>
|
||||
<button id="abort-game" type="button" class="text-button abort-button" hidden>Отменить партию</button>
|
||||
</section>
|
||||
|
||||
<section id="screen-result" class="screen" aria-labelledby="result-title" hidden>
|
||||
@@ -60,6 +60,7 @@
|
||||
<section id="screen-reconnecting" class="screen reconnecting" aria-labelledby="reconnecting-title" hidden><h2 id="reconnecting-title">Восстанавливаем связь</h2><p>Состояние игры обновляется через HTTP. WebSocket подключится автоматически.</p></section>
|
||||
<section id="screen-error" class="screen error-screen" aria-labelledby="error-title" hidden><h2 id="error-title">Не удалось продолжить</h2><p id="error-description"></p><button id="retry-button" type="button">Повторить</button></section>
|
||||
</main>
|
||||
<script src="/target_interaction.js" defer></script>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+5
-2
@@ -22,8 +22,11 @@ h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: .2rem; font-size: clamp(1.7
|
||||
.board-tabs { display: flex; gap: .5rem; margin: 1rem 0; } .board-tabs button { flex: 1; min-height: 2.5rem; background: #31536e; color: #f1f7ff; } .board-tabs button[aria-selected="true"] { background: #3ec6f0; color: #032035; }
|
||||
.boards { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; } .board { min-width: 0; padding: .75rem; border: 1px solid #50809c; border-radius: .8rem; background: #071f32; } .board h3 { margin: 0 0 .65rem; font-size: 1rem; }
|
||||
.board-grid { display: grid; grid-template-columns: 1.15rem repeat(10, minmax(0, 1fr)); gap: 2px; width: 100%; aspect-ratio: 1.1; } .axis { display: grid; place-items: center; color: #b8d3e6; font-size: clamp(.52rem, 2.2vw, .72rem); font-weight: 700; }
|
||||
.cell { min-height: 0; padding: 0; border-radius: .12rem; border: 1px solid #4e87a6; background: #167aa6; color: #fff; font-size: clamp(.68rem, 3vw, 1.15rem); line-height: 1; } .cell.ship { background: #b2c8d6; color: #1c3442; } .cell.miss::before { content: "•"; color: #08253a; font-size: 1.15em; } .cell.hit, .cell.sunk { background: #b83e42; color: #fff; } .cell.hit::before, .cell.sunk::before { content: "×"; font-size: 1.25em; font-weight: 900; } .cell.sunk { outline: 2px solid #ffe56a; outline-offset: -3px; } .cell.target { background: #0c648b; cursor: pointer; } .cell.selected { outline: 3px solid #ffe56a; outline-offset: -3px; } .cell[disabled] { opacity: 1; cursor: default; }
|
||||
.shot-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .7rem; margin-top: 1rem; } .shot-controls p { flex: 1 1 14rem; margin: 0; } .reconnecting { border-color: #ffe56a; } .error-screen { border-color: #ff8e80; }
|
||||
.cell { position: relative; min-height: 0; padding: 0; border-radius: .12rem; border: 1px solid #4e87a6; background: #167aa6; color: #fff; font-size: clamp(.68rem, 3vw, 1.15rem); line-height: 1; } .cell.ship { background: #b2c8d6; color: #1c3442; } .cell.ship::before { content: "◆"; font-size: .8em; } .cell.miss::before { content: "•"; color: #08253a; font-size: 1.15em; } .cell.hit, .cell.sunk { background: #b83e42; color: #fff; } .cell.hit::before, .cell.sunk::before { content: "×"; font-size: 1.25em; font-weight: 900; } .cell.sunk { outline: 2px solid #ffe56a; outline-offset: -3px; } .cell.target { background: #0c648b; cursor: pointer; touch-action: manipulation; } .cell.target::after { content: ""; position: absolute; inset: 27%; border: 1px dotted #b8edff; border-radius: 50%; } .cell.own-water { background: #167aa6; } .cell.unavailable { border-style: dashed; background: #0d4666; opacity: .72; } .cell.selected { outline: 3px solid #ffe56a; outline-offset: -3px; box-shadow: inset 0 0 0 2px #061827; } .cell.selected::after { content: "◎"; inset: auto; border: 0; color: #ffe56a; font-size: 1.05em; font-weight: 900; } .cell[disabled] { cursor: default; }
|
||||
.shot-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .7rem; margin-top: 1rem; } .shot-controls p { flex: 1 1 14rem; margin: 0; } .abort-button { margin-top: .75rem; color: #ffb7ae !important; } .reconnecting { border-color: #ffe56a; } .error-screen { border-color: #ff8e80; }
|
||||
@media (min-width: 44rem) { .app-shell { display: flex; flex-direction: column; min-height: 100svh; } .app-header { align-items: center; } .connection-status { margin: 0; } #screen-connect, #screen-lobby { width: min(100%, 48rem); margin: clamp(1.25rem, 4vh, 2.5rem) auto auto; padding: clamp(1.5rem, 4vw, 2.5rem); } #screen-connect .stack-form { width: min(100%, 42rem); max-width: 42rem; } #screen-connect .button-row > * { flex-basis: 16rem; } .mode-options { grid-template-columns: repeat(2, minmax(0, 1fr)); } .boards { grid-template-columns: repeat(2, minmax(0, 1fr)); } .board-tabs { display: none; } .board[hidden] { display: block; } }
|
||||
@media (min-width: 60rem) { #screen-connect { width: 100%; max-width: none; min-height: min(29rem, calc(100svh - 9rem)); display: grid; align-content: center; } #screen-connect .connection-layout { grid-template-columns: minmax(0, 1.15fr) minmax(18rem, .85fr); align-items: stretch; gap: clamp(1.5rem, 4vw, 3rem); } #screen-connect .stack-form { max-width: none; } #screen-connect .connection-support { display: flex; flex-direction: column; justify-content: center; } }
|
||||
@media (max-width: 43.99rem) { .app-shell { padding-inline: max(1.25rem, env(safe-area-inset-left)) max(1.25rem, env(safe-area-inset-right)); } .app-header { flex-direction: column; align-items: stretch; gap: .45rem; } .connection-status { align-self: flex-start; margin: 0; } #screen-connect { padding: 1.25rem; } .button-row { flex-direction: column; flex-wrap: nowrap; } .button-row > * { flex: 0 0 auto; width: 100%; min-height: 3rem; } #availability { margin-top: 0; } .board[hidden] { display: none; } .result-boards .board[hidden] { display: block; } }
|
||||
#screen-game { margin-top: .75rem; padding: 0; border: 0; background: transparent; box-shadow: none; } #screen-game .screen-heading { align-items: center; margin-bottom: .5rem; } #screen-game .screen-heading h2 { margin-bottom: .1rem; } #screen-game .turn-status { color: #e2f7ff; font-size: clamp(1rem, 2.5vw, 1.2rem); font-weight: 800; } #screen-game .score { margin: 0; border: 1px solid #41708d; } #screen-game .board { width: min(100%, 34rem); margin-inline: auto; padding: .65rem; box-shadow: 0 .65rem 1.5rem rgb(0 0 0 / 12%); } #screen-game .board-grid { max-width: 100%; margin-inline: auto; aspect-ratio: 1; grid-template-rows: repeat(11, minmax(0, 1fr)); } #screen-game .shot-controls { width: min(100%, 34rem); margin: .75rem auto 0; } #screen-game #fire-button { min-width: 8rem; } #screen-game .board-tabs { width: min(100%, 34rem); margin: .6rem auto; } #screen-game .board-tabs button { min-height: 2.75rem; }
|
||||
@media (max-width: 43.99rem) { #screen-game .screen-heading { gap: .55rem; } #screen-game .boards { gap: .65rem; } #screen-game .board { padding: .5rem; border-radius: .65rem; } #screen-game .shot-controls { display: grid; grid-template-columns: 1fr; gap: .55rem; } #screen-game .shot-controls p { margin: 0; } #screen-game #fire-button, #screen-game #cancel-target { width: 100%; min-height: 3rem; } }
|
||||
@media (min-width: 44rem) and (orientation: portrait) and (max-width: 64rem) { #screen-game .boards { grid-template-columns: minmax(0, 1fr); } #screen-game .board-tabs { display: flex; } #screen-game .board[hidden] { display: none; } }
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
(() => {
|
||||
function sameTarget(left, right) { return left?.x === right?.x && left?.y === right?.y; }
|
||||
|
||||
function createTargetActivator({ canFire, select, fire, thresholdMs = 360, now = () => Date.now() }) {
|
||||
let lastTap;
|
||||
let firing = false;
|
||||
|
||||
async function fireOnce(target) {
|
||||
if (firing || !canFire(target)) return false;
|
||||
firing = true;
|
||||
lastTap = undefined;
|
||||
try {
|
||||
await fire(target);
|
||||
return true;
|
||||
} finally {
|
||||
firing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function tap(target) {
|
||||
if (firing || !canFire(target)) return { action: 'ignored' };
|
||||
const timestampMs = now();
|
||||
if (lastTap && sameTarget(lastTap.target, target) && timestampMs - lastTap.timestampMs <= thresholdMs) {
|
||||
return { action: 'fire', promise: fireOnce(target) };
|
||||
}
|
||||
select(target);
|
||||
lastTap = { target, timestampMs };
|
||||
return { action: 'select' };
|
||||
}
|
||||
|
||||
function keyboard(target) {
|
||||
if (firing || !canFire(target)) return { action: 'ignored' };
|
||||
select(target);
|
||||
lastTap = undefined;
|
||||
return { action: 'select' };
|
||||
}
|
||||
|
||||
return {
|
||||
tap,
|
||||
keyboard,
|
||||
doubleActivate: (target) => ({ action: 'fire', promise: fireOnce(target) }),
|
||||
isFiring: () => firing,
|
||||
};
|
||||
}
|
||||
|
||||
const api = { createTargetActivator };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else globalThis.BattleshipTargetInteraction = api;
|
||||
})();
|
||||
@@ -0,0 +1,49 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { createTargetActivator } = require('../../data/target_interaction.js');
|
||||
|
||||
test('a single tap selects a target without firing', () => {
|
||||
const selected = [];
|
||||
let shots = 0;
|
||||
const activator = createTargetActivator({ canFire: () => true, select: target => selected.push(target), fire: async () => { shots += 1; } });
|
||||
assert.equal(activator.tap({ x: 1, y: 6 }).action, 'select');
|
||||
assert.deepEqual(selected, [{ x: 1, y: 6 }]);
|
||||
assert.equal(shots, 0);
|
||||
});
|
||||
|
||||
test('a second tap on the same cell fires once while a different cell selects', async () => {
|
||||
let timeMs = 0;
|
||||
const selected = [];
|
||||
let shots = 0;
|
||||
const activator = createTargetActivator({ canFire: () => true, select: target => selected.push(target), fire: async () => { shots += 1; }, now: () => timeMs });
|
||||
activator.tap({ x: 1, y: 6 });
|
||||
timeMs = 200;
|
||||
const sameCell = activator.tap({ x: 1, y: 6 });
|
||||
await sameCell.promise;
|
||||
timeMs = 300;
|
||||
assert.equal(activator.tap({ x: 2, y: 6 }).action, 'select');
|
||||
assert.equal(shots, 1);
|
||||
assert.deepEqual(selected, [{ x: 1, y: 6 }, { x: 2, y: 6 }]);
|
||||
});
|
||||
|
||||
test('double activation and explicit fire are deduplicated while a shot is pending', async () => {
|
||||
let resolveShot;
|
||||
let shots = 0;
|
||||
const activator = createTargetActivator({ canFire: () => true, select: () => {}, fire: () => new Promise(resolve => { shots += 1; resolveShot = resolve; }) });
|
||||
const first = activator.doubleActivate({ x: 1, y: 6 });
|
||||
const duplicate = activator.doubleActivate({ x: 1, y: 6 });
|
||||
assert.equal(await duplicate.promise, false);
|
||||
assert.equal(shots, 1);
|
||||
resolveShot();
|
||||
await first.promise;
|
||||
});
|
||||
|
||||
test('out-of-turn or already-targeted cells cannot select or fire', () => {
|
||||
let selected = 0;
|
||||
let shots = 0;
|
||||
const activator = createTargetActivator({ canFire: () => false, select: () => { selected += 1; }, fire: async () => { shots += 1; } });
|
||||
assert.equal(activator.tap({ x: 1, y: 6 }).action, 'ignored');
|
||||
assert.equal(activator.keyboard({ x: 1, y: 6 }).action, 'ignored');
|
||||
assert.equal(selected, 0);
|
||||
assert.equal(shots, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user