feat: add label random and animations to web interface
This commit is contained in:
+178
-4
@@ -9,7 +9,9 @@
|
||||
start: el('start-game'), boards: el('boards'), resultBoards: el('result-boards'), tabs: el('board-tabs'),
|
||||
turn: el('turn-status'), wins: el('wins'), shotControls: el('shot-controls'), target: el('target-status'),
|
||||
fire: el('fire-button'), cancel: el('cancel-target'), abort: el('abort-game'), rematch: el('rematch-button'),
|
||||
result: el('result-description'), error: el('error-description')
|
||||
result: el('result-description'), error: el('error-description'), lobbyIcon: el('lobby-icon'), resultIcon: el('result-icon'),
|
||||
cue: el('action-cue'), cueIcon: el('action-cue-icon'), cueText: el('action-cue-text'), effects: el('game-effects'),
|
||||
effectIcon: el('effect-icon'), effectText: el('effect-text'), effectBurst: el('effect-burst'), effectBadge: el('effect-badge')
|
||||
};
|
||||
let token = localStorage.getItem(storage.token) || '';
|
||||
let role = '';
|
||||
@@ -27,6 +29,164 @@
|
||||
let firePending = false;
|
||||
let targetActivator;
|
||||
let retryIndex = 0;
|
||||
let effectTimer;
|
||||
let hitStreak = 0;
|
||||
|
||||
const reaction = (kind, icon, text, motion = 'pop', burst = 'none', vibration = [45]) => ({ kind, icon, text, motion, burst, vibration });
|
||||
const feedback = {
|
||||
start: [
|
||||
reaction('start', 'ship-4-battleship', 'Бой начинается!', 'swoop', 'bubbles', [80]),
|
||||
reaction('start', 'icon-rocket', 'Полный вперёд!', 'zoom', 'sparks', [50, 30, 90]),
|
||||
reaction('start', 'ship-3-cruiser', 'Поднять якоря!', 'bounce', 'bubbles', [90]),
|
||||
reaction('start', 'icon-target', 'К бою готовы!', 'spin', 'stars', [60, 30, 60]),
|
||||
],
|
||||
turn: [
|
||||
reaction('turn', 'icon-target', 'Твой ход!', 'pop', 'sparks', [80, 40, 80]),
|
||||
reaction('turn', 'icon-blast', 'Капитан, выбирай!', 'bounce', 'stars', [70, 30, 70]),
|
||||
reaction('turn', 'icon-target', 'Пора пулять!', 'zoom', 'sparks', [90]),
|
||||
reaction('turn', 'ship-1-cutter', 'Твоя очередь!', 'swoop', 'bubbles', [60, 30, 80]),
|
||||
reaction('turn', 'icon-target', 'Где прячется корабль?', 'spin', 'stars', [60]),
|
||||
reaction('turn', 'icon-target', 'Твой мув!', 'zoom', 'sparks', [70]),
|
||||
],
|
||||
hit: [
|
||||
reaction('hit', 'icon-blast', 'Попадание!', 'pop', 'sparks', [110]),
|
||||
reaction('hit', 'icon-target', 'Точно в цель!', 'zoom', 'stars', [80, 25, 120]),
|
||||
reaction('hit', 'icon-blast', 'Нннааа!', 'spin', 'sparks', [130]),
|
||||
reaction('hit', 'ship-1-cutter', 'Есть контакт!', 'swoop', 'stars', [70, 30, 100]),
|
||||
reaction('hit', 'icon-blast', 'Мочный выстрел!', 'bounce', 'stars', [90, 30, 120]),
|
||||
reaction('hit', 'icon-target', 'Прямо в яички!', 'zoom', 'sparks', [120]),
|
||||
reaction('hit', 'icon-blast', 'КРИТ!', 'zoom', 'sparks', [90, 20, 130]),
|
||||
reaction('hit', 'icon-trophy', 'ИМБА!', 'spin', 'stars', [80, 30, 120]),
|
||||
reaction('hit', 'icon-target', 'Лютый пострил!', 'bounce', 'stars', [100]),
|
||||
reaction('hit', 'icon-blast', 'Опасное попадание!', 'swoop', 'sparks', [100]),
|
||||
],
|
||||
sunk: [
|
||||
reaction('hit', 'icon-trophy', 'Этот уже не похилится!', 'spin', 'stars', [90, 40, 140]),
|
||||
reaction('hit', 'icon-blast', 'Минус один!', 'zoom', 'sparks', [100, 40, 150]),
|
||||
reaction('hit', 'icon-trophy', 'Вот это капитан!', 'bounce', 'stars', [80, 30, 80, 30, 140]),
|
||||
reaction('hit', 'icon-wave', 'Лодка буль-буль!', 'swoop', 'bubbles', [120, 50, 150]),
|
||||
reaction('hit', 'ship-4-battleship', 'Победа будет наша!', 'pop', 'stars', [90, 40, 130]),
|
||||
reaction('hit', 'icon-trophy', 'Ты Босс!', 'zoom', 'stars', [100, 40, 160]),
|
||||
reaction('hit', 'icon-blast', 'Ультра-урон!', 'spin', 'sparks', [110, 40, 150]),
|
||||
reaction('hit', 'icon-trophy', 'Это было Жопично!', 'bounce', 'stars', [100, 40, 150]),
|
||||
],
|
||||
miss: [
|
||||
reaction('miss', 'icon-wave', 'Мимо!', 'swoop', 'bubbles', [45]),
|
||||
reaction('miss', 'icon-wave', 'Буль-буль!', 'bounce', 'bubbles', [35]),
|
||||
reaction('miss', 'icon-target', 'Чуть-чуть не попал!', 'pop', 'bubbles', [40]),
|
||||
reaction('miss', 'icon-wave', 'Рыбки увернулись!', 'spin', 'bubbles', [30, 20, 30]),
|
||||
reaction('miss', 'icon-wave', 'Волна поймала снаряд!', 'zoom', 'bubbles', [45]),
|
||||
reaction('miss', 'icon-target', 'Не угадал!', 'swoop', 'none', [35]),
|
||||
reaction('miss', 'icon-wave', 'А там ничего нет!', 'spin', 'bubbles', [40]),
|
||||
reaction('miss', 'icon-target', 'Почти хайлайт!', 'zoom', 'none', [35]),
|
||||
reaction('miss', 'icon-wave', 'Вода: 1. Снаряд: 0.', 'bounce', 'bubbles', [35]),
|
||||
],
|
||||
dodged: [
|
||||
reaction('miss', 'icon-wave', 'Не попали!', 'swoop', 'bubbles', [40]),
|
||||
reaction('miss', 'ship-1-cutter', 'В этот раз повезло!', 'bounce', 'stars', [35, 20, 35]),
|
||||
reaction('miss', 'icon-wave', 'Дуракам везет!', 'pop', 'bubbles', [45]),
|
||||
reaction('miss', 'ship-2-destroyer', 'Косоглазые!', 'zoom', 'bubbles', [35]),
|
||||
],
|
||||
damage: [
|
||||
reaction('damage', 'icon-blast', 'В нас попали!', 'zoom', 'sparks', [160]),
|
||||
reaction('damage', 'ship-2-destroyer', 'Нас вычислили!', 'swoop', 'sparks', [140, 40, 100]),
|
||||
reaction('damage', 'icon-blast', 'Спасайся кто может!', 'spin', 'sparks', [170]),
|
||||
reaction('damage', 'ship-1-cutter', 'A-a-a ранен!', 'bounce', 'none', [150]),
|
||||
],
|
||||
'sunk-damage': [
|
||||
reaction('damage', 'icon-wave', 'Наш корабль потоплен!', 'swoop', 'bubbles', [180, 60, 180]),
|
||||
reaction('damage', 'ship-4-battleship', 'Еще не все пропало!', 'bounce', 'sparks', [150, 50, 130]),
|
||||
reaction('damage', 'icon-repeat', 'Не сдаёмся!', 'zoom', 'stars', [130, 40, 160]),
|
||||
reaction('damage', 'icon-blast', 'Мы отомстим!', 'spin', 'sparks', [160]),
|
||||
],
|
||||
victory: [
|
||||
reaction('victory', 'icon-trophy', 'Победа!', 'pop', 'stars', [80, 40, 80, 40, 180]),
|
||||
reaction('victory', 'icon-trophy', 'БОСС моря!', 'spin', 'stars', [70, 30, 70, 30, 170]),
|
||||
reaction('victory', 'ship-4-battleship', 'Мы их всех утопили!', 'swoop', 'bubbles', [90, 40, 160]),
|
||||
reaction('victory', 'icon-trophy', 'Вот это победа!', 'bounce', 'stars', [80, 30, 90, 30, 180]),
|
||||
reaction('victory', 'icon-rocket', 'Капитан — суперзвезда!', 'zoom', 'sparks', [100, 40, 180]),
|
||||
reaction('victory', 'icon-trophy', 'ЛЕГЕНДА!', 'spin', 'stars', [90, 30, 90, 30, 190]),
|
||||
reaction('victory', 'icon-trophy', 'Вот это скилл!', 'bounce', 'stars', [80, 30, 180]),
|
||||
reaction('victory', 'icon-rocket', 'GG! Красиво!', 'zoom', 'sparks', [100, 40, 180]),
|
||||
],
|
||||
defeat: [
|
||||
reaction('damage', 'icon-repeat', 'Поиграем еще раз?', 'spin', 'stars', [120]),
|
||||
reaction('damage', 'ship-3-cruiser', 'В следующий раз порву!', 'swoop', 'bubbles', [100, 40, 120]),
|
||||
reaction('damage', 'icon-repeat', 'В плен не сдаемся!', 'bounce', 'stars', [110]),
|
||||
reaction('damage', 'icon-target', 'Попробуем ещё раз!', 'zoom', 'sparks', [100]),
|
||||
],
|
||||
finish: [
|
||||
reaction('victory', 'icon-trophy', 'Бой окончен!', 'pop', 'stars', [80]),
|
||||
reaction('victory', 'ship-4-battleship', 'Вот это битва!', 'swoop', 'bubbles', [80]),
|
||||
reaction('victory', 'icon-trophy', 'Морской бой завершён!', 'spin', 'stars', [80]),
|
||||
],
|
||||
'watch-hit': [
|
||||
reaction('hit', 'icon-blast', 'Уууууу!', 'pop', 'sparks', [80]),
|
||||
reaction('hit', 'icon-target', 'Попал!', 'zoom', 'stars', [80]),
|
||||
reaction('hit', 'icon-blast', 'Нннааа!', 'spin', 'sparks', [80]),
|
||||
],
|
||||
'watch-sunk': [
|
||||
reaction('hit', 'icon-trophy', 'Корабль потоплен!', 'bounce', 'stars', [100]),
|
||||
reaction('hit', 'icon-wave', 'Ушёл под воду!', 'swoop', 'bubbles', [100]),
|
||||
reaction('hit', 'icon-blast', 'Вот это выстрел!', 'zoom', 'sparks', [100]),
|
||||
],
|
||||
'watch-miss': [
|
||||
reaction('miss', 'icon-wave', 'Мимо!', 'swoop', 'bubbles', [40]),
|
||||
reaction('miss', 'icon-wave', 'Буль!', 'bounce', 'bubbles', [40]),
|
||||
reaction('miss', 'icon-target', 'Почти попал!', 'pop', 'none', [40]),
|
||||
],
|
||||
};
|
||||
const pickReaction = globalThis.BattleshipTargetInteraction.createReactionPicker(feedback);
|
||||
|
||||
function setSpriteIcon(container, symbol) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('class', 'ui-icon'); svg.setAttribute('aria-hidden', 'true');
|
||||
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
||||
use.setAttribute('href', `/ship-sprite.svg#${symbol}`); svg.append(use); container.replaceChildren(svg);
|
||||
}
|
||||
|
||||
function fillBurst(burst) {
|
||||
ui.effectBurst.replaceChildren(); ui.effectBurst.className = `effect-burst burst-${burst}`;
|
||||
if (burst === 'none') return;
|
||||
const symbols = burst === 'bubbles' ? ['○', 'o', '·'] : burst === 'sparks' ? ['✦', '+', '*'] : ['★', '✦', '*'];
|
||||
const count = burst === 'bubbles' ? 14 : burst === 'stars' ? 22 : 18;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const particle = document.createElement('span');
|
||||
const angle = (Math.PI * 2 * index) / count;
|
||||
const distance = 35 + (index % 4) * 12;
|
||||
particle.textContent = symbols[index % symbols.length];
|
||||
particle.style.setProperty('--x', `${Math.cos(angle) * distance}vw`);
|
||||
particle.style.setProperty('--y', `${Math.sin(angle) * distance}vh`);
|
||||
particle.style.setProperty('--spin', `${(index % 2 ? 1 : -1) * (140 + index * 13)}deg`);
|
||||
particle.style.setProperty('--rotate', `${index * 23}deg`);
|
||||
particle.style.setProperty('--delay', `${(index % 5) * 35}ms`);
|
||||
ui.effectBurst.append(particle);
|
||||
}
|
||||
}
|
||||
|
||||
function showFeedback(event) {
|
||||
const content = pickReaction(event?.type);
|
||||
if (!content) return;
|
||||
let { motion, burst } = content;
|
||||
const { kind, icon, text, vibration } = content;
|
||||
if (event.type === 'hit' || event.type === 'sunk') hitStreak += 1;
|
||||
else if (event.type === 'miss' || event.type === 'start' || event.type === 'victory' || event.type === 'defeat') hitStreak = 0;
|
||||
const combo = hitStreak >= 2;
|
||||
if (combo) { motion = hitStreak >= 3 ? 'combo' : motion; burst = 'stars'; }
|
||||
window.clearTimeout(effectTimer);
|
||||
ui.effects.className = `game-effects effect-${kind} motion-${motion}${combo ? ' combo-power' : ''}`;
|
||||
setSpriteIcon(ui.effectIcon, icon); ui.effectText.textContent = text;
|
||||
ui.effectBadge.hidden = !combo;
|
||||
ui.effectBadge.textContent = combo ? (hitStreak >= 4 ? `УЛЬТРА-КОМБО ×${hitStreak}` : hitStreak === 3 ? 'МЕГА-КОМБО ×3' : 'КОМБО ×2') : '';
|
||||
fillBurst(burst); ui.effects.hidden = false;
|
||||
document.body.classList.remove('fx-hit', 'fx-damage');
|
||||
void document.body.offsetWidth;
|
||||
document.body.classList.add(kind === 'damage' ? 'fx-damage' : 'fx-hit');
|
||||
if (navigator.vibrate) navigator.vibrate(vibration);
|
||||
effectTimer = window.setTimeout(() => {
|
||||
ui.effects.hidden = true; document.body.classList.remove('fx-hit', 'fx-damage');
|
||||
}, kind === 'victory' ? 2200 : combo ? 1800 : 1350);
|
||||
}
|
||||
|
||||
function showScreen(name) {
|
||||
screens.forEach(screen => { el(`screen-${screen}`).hidden = screen !== name; });
|
||||
@@ -118,12 +278,14 @@
|
||||
function acceptState(payload) {
|
||||
if (!safeState(payload)) { showError('Получено неполное состояние игры.'); return; }
|
||||
const hadGap = state && payload.version > state.version + 1;
|
||||
const previousState = state;
|
||||
state = payload;
|
||||
role = payload.viewer;
|
||||
if (hadGap) pollState();
|
||||
selectedTarget = selectedTarget && payload.boards[opponentIndex()][selectedTarget.y * 10 + selectedTarget.x] === '0' ? selectedTarget : undefined;
|
||||
document.title = `Морской бой — версия ${payload.version}`;
|
||||
render();
|
||||
showFeedback(globalThis.BattleshipTargetInteraction.detectFeedback(previousState, payload, role));
|
||||
}
|
||||
|
||||
function isPlayer() { return role === 'player1' || role === 'player2'; }
|
||||
@@ -233,7 +395,7 @@
|
||||
|
||||
function boardElement(index, title, targetable, result) {
|
||||
const board = document.createElement('section');
|
||||
board.className = 'board';
|
||||
board.className = `board${targetable ? ' is-action' : ''}`;
|
||||
board.dataset.board = String(index);
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = title;
|
||||
@@ -322,6 +484,7 @@
|
||||
else stopLobbyInfoPolling();
|
||||
const playerTwoReady = info?.player2Available === false;
|
||||
const canStart = playerOne && (state.mode === 'bot' || playerTwoReady);
|
||||
setSpriteIcon(ui.lobbyIcon, canStart ? 'icon-check' : 'icon-hourglass');
|
||||
document.querySelectorAll('.mode-button').forEach(button => {
|
||||
const selected = button.dataset.mode === state.mode;
|
||||
button.setAttribute('aria-checked', String(selected));
|
||||
@@ -341,10 +504,14 @@
|
||||
ui.wins.textContent = `${playerLabel(0)} ${state.wins[0]} : ${state.wins[1]} ${playerLabel(1)}`;
|
||||
renderBoards(ui.boards);
|
||||
const available = canShoot();
|
||||
ui.cue.classList.toggle('is-ready', available && !selectedTarget);
|
||||
ui.cue.classList.toggle('has-target', Boolean(available && selectedTarget));
|
||||
setSpriteIcon(ui.cueIcon, available ? (selectedTarget ? 'icon-blast' : 'icon-target') : 'icon-hourglass');
|
||||
ui.cueText.textContent = available ? (selectedTarget ? 'Жми «Огонь»!' : 'Твой ход! Выбери клетку') : 'Ждём соперника…';
|
||||
ui.shotControls.hidden = !isPlayer();
|
||||
ui.target.textContent = firePending ? 'Выстрел отправляется…' : available ? (selectedTarget ? `Цель: ${columns[selectedTarget.x]}${selectedTarget.y + 1}` : 'Выберите клетку на поле соперника.') : 'Ожидайте своего хода.';
|
||||
ui.fire.disabled = !selectedTarget || !canFireTarget(selectedTarget);
|
||||
ui.fire.textContent = firePending ? 'Выстрел…' : 'Огонь';
|
||||
ui.fire.querySelector('span:last-child').textContent = firePending ? 'Летит…' : 'Огонь!';
|
||||
ui.cancel.disabled = !selectedTarget || firePending;
|
||||
ui.abort.hidden = !(role === 'player1' && state.mode === 'human' && state.phase === 'in_progress');
|
||||
}
|
||||
@@ -355,12 +522,13 @@
|
||||
if (statisticsGameId !== state.gameId) refreshStatistics();
|
||||
const waiting = state.phase === 'rematch_wait';
|
||||
const winner = state.winner === ownIndex() && isPlayer() ? 'Вы победили. ' : state.winner === 0 || state.winner === 1 ? `Победил ${playerLabel(state.winner)}. ` : '';
|
||||
setSpriteIcon(ui.resultIcon, isPlayer() && state.winner === ownIndex() ? 'icon-trophy' : 'icon-repeat');
|
||||
const matchSummary = state.statistics.map((entry, index) => `${playerLabel(index)}: выстрелы ${entry[0]}, попадания ${entry[1]}, промахи ${entry[2]}, потоплено ${entry[3]}.`).join(' ');
|
||||
const cumulative = cumulativeStatistics?.cumulative?.map((entry, index) => `${playerLabel(index)} всего: игр ${entry[0]}, побед ${entry[1]}, поражений ${entry[2]}, выстрелов ${entry[4]}, попаданий ${entry[5]}, промахов ${entry[6]}, потоплено ${entry[3]}.`).join(' ') || '';
|
||||
ui.result.textContent = `${winner}${matchSummary} ${cumulative} ${waiting ? 'Ожидается подтверждение повторной игры.' : 'Поля раскрыты. Можно подтвердить повторную игру.'}`;
|
||||
ui.rematch.hidden = !isPlayer();
|
||||
ui.rematch.disabled = !isPlayer();
|
||||
ui.rematch.textContent = waiting ? 'Подтвердить повторно' : 'Сыграть ещё';
|
||||
ui.rematch.querySelector('span:last-child').textContent = waiting ? 'Я готов!' : 'Ещё раз!';
|
||||
renderBoards(ui.resultBoards, true);
|
||||
}
|
||||
|
||||
@@ -439,6 +607,11 @@
|
||||
const requestedRole = info?.player1Available ? 'player1' : info?.player2Available ? 'player2' : 'spectator';
|
||||
join(requestedRole);
|
||||
});
|
||||
document.querySelectorAll('.avatar-button').forEach(button => button.addEventListener('click', () => {
|
||||
ui.name.value = button.dataset.name;
|
||||
document.querySelectorAll('.avatar-button').forEach(option => option.setAttribute('aria-pressed', String(option === button)));
|
||||
el('join-player').focus();
|
||||
}));
|
||||
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 }));
|
||||
@@ -450,6 +623,7 @@
|
||||
|
||||
async function boot() {
|
||||
ui.name.value = localStorage.getItem(storage.name) || '';
|
||||
document.querySelectorAll('.avatar-button').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.name === ui.name.value)));
|
||||
setConnection('Проверяем ESP32…');
|
||||
await refreshInfo();
|
||||
if (!token) { showScreen('connect'); setConnection('Готово к подключению'); return; }
|
||||
|
||||
Reference in New Issue
Block a user