diff --git a/PLANS.md b/PLANS.md
index 7a0e6d6..c559b80 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -1552,3 +1552,262 @@ At completion, provide the reference sheet, screenshots of the mobile and tablet
- Measurements: `node --test test/web/test_target_interaction.js test/web/test_ship_sprite.js` passed 7/7, including the class-specific viewport assertion; `make -C test/host run`, `node --check data/app.js`, `git diff --check`, `pio run -e esp32-c6-devkitm-1 -t buildfs`, and `pio run -e esp32-c6-devkitm-1` passed. The sprite changed from 1,032 B to 975 B raw and from 419 B to 496 B gzip; it remains below the 15 KiB / 5 KiB targets. The generated LittleFS image remains the fixed 2,031,616-B partition image (0-B image-size delta). Firmware uses 39,588 / 327,680 B RAM (12.1%) and 1,019,248 / 2,097,152 B flash (48.6%). Static assets now use `no-cache`, so an updated filesystem upload is revalidated instead of retaining the prior sprite/app bundle.
- Issues or deviations: This environment has neither a Chromium executable nor an attached browser backend, so screenshots and live mobile/tablet inspection could not be captured. No firmware or filesystem upload was performed.
- Next action: No subsequent milestone was started.
+
+---
+
+## Milestone 021 — Define and implement safe leave, profile reset, and full game reset semantics
+
+**Status:** `READY`
+**Depends on:** Milestone 020
+
+### Objective
+
+Add bounded, authoritative recovery operations that let a user leave, clear only their local profile, or reset the complete in-memory game when the party becomes unusable.
+
+This milestone establishes the behavior and server contract before adding the interface controls. The operations must be safe in every phase from the lobby through the result and rematch screens.
+
+### Reset scopes
+
+Implement three deliberately different operations.
+
+1. **Leave the game**
+ - Invalidate and release only the requesting session.
+ - Clear the session token in that browser and return it to the registration screen.
+ - Preserve the locally remembered display name and selected hero so the same user can rejoin quickly.
+ - A spectator leaving must not change the match.
+ - If an active player leaves during a human-vs-human match, abort the current match safely, return the remaining valid player to the lobby, release the departed seat, and allow a replacement player to join.
+ - Leaving a bot match must abort that match and release the human player's seat.
+
+2. **Reset my profile**
+ - Perform the same server-side session release as leaving.
+ - Clear the requesting browser's session token, display name, selected hero, and other user-specific browser preferences.
+ - Do not erase another user's local data or unrelated device-wide game state.
+ - Return the browser to the initial registration screen with an empty profile.
+
+3. **Reset the entire game**
+ - Be available only to a currently authenticated player, never to a spectator or anonymous client.
+ - Atomically clear both player sessions, spectator sessions, names, selected mode, boards, turn, winner, rematch approvals, bot state, match statistics, cumulative in-memory statistics, queued commands, and active WebSocket ownership.
+ - Create a fresh lobby/game generation so stale commands from the previous party cannot mutate the new state.
+ - Notify every connected client that the game was reset, invalidate every browser session, and return all clients to the initial registration screen.
+ - Work from `LOBBY`, `PREPARING`, `IN_PROGRESS`, `FINISHED`, and `REMATCH_WAIT`.
+
+The full game reset is an application recovery operation. It must **not** erase Wi-Fi credentials, LittleFS assets, firmware, flash partitions, board configuration, or other device settings.
+
+### Server and lifecycle work
+
+- Define stable routes or commands for session leave and full game reset using the existing validated application/command-queue path.
+- Require a valid bounded session token for every state-changing recovery request.
+- Require the current `gameId` or reset generation where applicable so delayed requests are rejected as stale.
+- Make repeated leave and reset requests idempotent: retries must not corrupt state, double-increment counters, or release a newly created session.
+- Add an explicit machine-readable reset reason such as `session_left`, `profile_reset`, or `game_reset` instead of forcing the browser to infer recovery from a generic authorization error.
+- Broadcast a minimal public reset notification before invalidating or closing affected WebSocket connections. Do not include former tokens, hidden boards, or private session data.
+- Ensure HTTP polling with an invalidated token receives the same stable recovery reason.
+- Clear or invalidate queued commands from the previous generation before a new player can act.
+- Do not hold the application mutex while closing sockets or sending network responses; take a bounded reset snapshot and complete transport cleanup outside the critical section.
+- Keep all request bodies, responses, queues, and reset notifications within the existing resource budgets.
+
+### Authorization and abuse resistance
+
+- Anonymous clients and spectators must receive a stable forbidden response for full reset.
+- Either authenticated player may use full reset as an emergency recovery action on the trusted home network.
+- Confirmation is a browser responsibility added in Milestone 022; the server must still validate role, token, phase/generation, body bounds, and command freshness independently.
+- A stale page from a previous party must not be able to reset or leave a newly created party using an old token or `gameId`.
+- Reset endpoints must not expose whether a guessed token belongs to a particular named player.
+
+### Tests
+
+Add host tests covering at least:
+
+1. Spectator leave in every phase without match mutation.
+2. Player leave from the lobby and release of the correct seat.
+3. Player leave during human-vs-human play and safe return of the remaining player to the lobby.
+4. Player leave during bot play and safe match abort.
+5. Full reset from every supported phase.
+6. Clearing of sessions, names, mode, boards, bot state, rematch state, match statistics, and cumulative statistics.
+7. Reset-generation change and rejection of stale queued commands.
+8. Idempotent retry of leave and full reset.
+9. Rejection of anonymous, spectator, malformed, oversized, and stale reset requests.
+10. Role-safe reset notification and HTTP recovery response.
+11. Recovery while WebSocket clients and HTTP pollers are connected.
+12. No mutation when the command queue is full or the reset request is rejected.
+
+### Acceptance criteria
+
+- The three operations have distinct, documented scopes and cannot be confused by the client.
+- Leave and profile reset affect only the requesting browser/session, subject to the documented active-player match-abort rule.
+- Full reset returns the complete application to a clean pre-registration state without rebooting the ESP32.
+- Every connected client learns about a full reset and discards its invalid session.
+- Stale tokens, game IDs, and queued commands cannot affect the fresh party.
+- Spectators and anonymous clients cannot reset the complete game.
+- No Wi-Fi, filesystem, firmware, or hardware configuration is erased.
+- All host suites pass within the established memory and payload budgets.
+
+### Completion action
+
+When all criteria pass, set Milestone 021 to `DONE`, append its execution record, and change Milestone 022 from `BLOCKED` to `READY`. Do not start Milestone 022 in the same task unless explicitly requested.
+
+---
+
+## Milestone 022 — Add the always-available recovery menu and child-safe confirmations
+
+**Status:** `BLOCKED`
+**Depends on:** Milestone 021
+
+### Objective
+
+Add one clearly recognizable recovery button that is available from the lobby through the complete game lifecycle and opens actions for leaving, resetting the current profile, or resetting the entire game.
+
+The design must prevent accidental destructive taps while remaining understandable to young children who may not read confidently.
+
+### Button availability and placement
+
+- Show the recovery/menu button on `LOBBY`, `PREPARING`, `IN_PROGRESS`, `FINISHED`, and `REMATCH_WAIT` screens.
+- Keep it available on the HTTP-recovery/reconnecting screen so a user can at least clear their local session when the network path is unhealthy.
+- Do not show it on the initial registration screen, because there is no active session to leave.
+- Place it consistently in the header or another fixed safe area where it does not cover the board, score, field tabs, Fire control, or browser safe-area insets.
+- Use a local SVG symbol such as a door, lifebuoy, or reset arrow plus a concise text/accessibility label. Do not rely on color or text alone.
+- Keep a minimum touch target of 44 × 44 CSS pixels.
+
+### Recovery menu
+
+Opening the button must present three visibly distinct choices:
+
+1. **Выйти из игры** — leave while keeping the remembered name/hero.
+2. **Сбросить мой профиль** — leave and clear this browser's name, hero, token, and user preferences.
+3. **Сбросить всю игру** — clear the complete in-memory party for every connected user.
+
+- Show the full-game option only to authenticated players.
+- Never show or enable the full-game option for spectators or anonymous/recovery-only clients.
+- Use a modal or bottom sheet with a clear close control, focus trapping, Escape support, and restoration of focus to the menu button.
+- The default focused action must be Cancel, not a destructive action.
+- Prevent background board interaction while the menu or confirmation is open.
+
+### Confirmation behavior
+
+- Every leave or reset action requires a separate confirmation step.
+- The confirmation must state the scope visually and in text:
+ - one-person icon for local leave/profile reset;
+ - all-players icon for full game reset.
+- Use two clearly separated choices: Cancel and the requested action.
+- Full game reset requires a stronger final gesture: press and hold the destructive button for approximately two seconds while a visible progress ring/bar fills.
+- Releasing early cancels the hold without sending a request.
+- Keyboard and assistive-technology users must have an equivalent explicit confirmation path.
+- Do not require typing a phrase; the recovery flow must remain usable by children who cannot read or type confidently.
+- Disable the action after submission and show bounded progress so double taps cannot enqueue duplicate requests.
+- If the request fails, keep the user in a recoverable state and offer Retry or local-only profile clearing where safe.
+
+### Browser behavior
+
+- On successful leave, remove only the session token and render the registration screen with the remembered name/hero.
+- On successful profile reset, remove all Battleship user keys from `localStorage` and render an empty registration screen.
+- On a full reset notification, every client must remove all session/profile keys, stop WebSocket retries and polling for the invalid session, clear transient UI/game state, and render the empty registration screen.
+- Clear selected targets, pending Fire state, animation timers, combo counters, notices, cumulative-statistics cache, active-board selection, and retry timers during local or global reset.
+- Do not briefly render hidden boards or stale names while transitioning to registration.
+- If the browser is offline or the server is unreachable, allow local profile clearing only after confirmation and explain that the server seat may remain occupied until its normal timeout or a later successful leave.
+
+### Child-friendly visual requirements
+
+- Distinguish the three choices using shape, icon, spacing, and label, not red color alone.
+- Keep the most destructive action visually separated from the two local actions.
+- Use calm wording; do not make failure or reset feel like punishment.
+- Avoid flashing the destructive confirmation red repeatedly.
+- Respect `prefers-reduced-motion` for the hold progress and transition effects.
+- Provide complete Russian accessible labels and live status announcements.
+
+### Browser tests
+
+Add tests covering at least:
+
+1. Recovery button visibility in every supported phase and its absence on registration.
+2. Role-specific visibility of the full-game option.
+3. Cancel from every confirmation without API submission or local-data loss.
+4. Successful leave while preserving remembered name/hero.
+5. Successful profile reset clearing every Battleship browser key.
+6. Two-second hold requirement for full reset and cancellation on early release.
+7. Request deduplication during repeated taps, pointer events, and keyboard activation.
+8. Reset notification handling through WebSocket and HTTP fallback.
+9. Clearing timers, selected targets, combo state, cached statistics, and stale UI after reset.
+10. Offline local-profile recovery and its warning.
+11. Focus trapping, Escape behavior, focus restoration, and accessible announcements.
+12. Mobile, tablet portrait, tablet landscape, and laptop layout without board overlap.
+
+### Acceptance criteria
+
+- A user can reach the recovery menu from the lobby through the result/rematch flow without scrolling past the game board.
+- No leave or reset occurs from a single accidental tap on the menu button.
+- Full reset requires an authenticated player and the stronger confirmation gesture.
+- Local leave, local profile reset, and full game reset produce their documented distinct outcomes.
+- All affected clients return to the correct registration or lobby state without stale data.
+- The flow works with touch, mouse, keyboard, assistive technology, WebSocket, and HTTP fallback.
+- The controls remain legible and unobtrusive at the target phone and tablet sizes.
+- JavaScript, CSS, SVG, gzip, and LittleFS budgets remain acceptable.
+
+### Completion action
+
+When all criteria pass, set Milestone 022 to `DONE`, append its execution record, and change Milestone 023 from `BLOCKED` to `READY`. Do not start Milestone 023 in the same task unless explicitly requested.
+
+---
+
+## Milestone 023 — Verify multi-client reset recovery and document the emergency workflow
+
+**Status:** `BLOCKED`
+**Depends on:** Milestone 022
+
+### Objective
+
+Prove that leave, profile reset, and full game reset recover real phones/tablets and the ESP32 consistently under normal play, reconnect, HTTP fallback, and partially broken client conditions.
+
+### Integration scenarios
+
+Run and record at least the following scenarios with two player clients and one spectator where applicable:
+
+1. Player leaves from the lobby; the correct seat becomes available and the other user remains valid.
+2. Spectator leaves during play; the match and both player sessions remain unchanged.
+3. Player leaves during human-vs-human play; the match aborts safely and the remaining player returns to the lobby.
+4. Human leaves a bot match; the bot stops and the game returns to a clean lobby.
+5. User resets only their profile and can register again under a new name/hero.
+6. Player performs full reset from the lobby.
+7. Player performs full reset during active play while another client has a selected target.
+8. Player performs full reset from the result and rematch screens.
+9. Full reset while one client is on WebSocket and another is using HTTP polling.
+10. Full reset while one browser is temporarily disconnected and reconnects with an invalidated token.
+11. Repeated confirmation taps and retried HTTP requests do not execute more than one reset.
+12. A stale pre-reset tab cannot alter the newly registered party.
+13. ESP32 remains responsive after at least 50 mixed leave/profile-reset/full-reset cycles.
+
+### Verification and measurements
+
+- Run all host lifecycle, API, synchronization, integration, robustness, and browser tests.
+- Build firmware and LittleFS from a clean state.
+- Record firmware flash/RAM use, LittleFS usage, reset payload sizes, minimum free heap, largest free block, and connection count before and after the reset-cycle run.
+- Confirm that free heap has no persistent downward trend across the repeated cycle test.
+- Confirm there is no watchdog reset, socket leak, stuck seat, stale name, stale board, duplicate command, or orphaned bot action.
+- Verify the recovery menu at target mobile and tablet sizes, including safe areas and landscape orientation.
+- Verify that the full-reset hold interaction cannot accidentally fire a board cell underneath it.
+- Confirm no reset operation erases Wi-Fi credentials or requires a device reboot.
+
+### Documentation
+
+Document the user-facing emergency workflow in concise Russian:
+
+- how to leave while keeping a profile;
+- how to clear only the current browser profile;
+- how a player resets the entire party;
+- what happens to the other connected users;
+- what to do when the browser is offline;
+- explicit assurance that Wi-Fi and firmware settings are not erased.
+
+Also document the API routes/commands, authorization, stable reset reason codes, idempotency behavior, and reset-generation rules for future maintainers.
+
+### Acceptance criteria
+
+- Every integration scenario passes on the target ESP32 with objective logs or screenshots.
+- All clients converge on the correct post-reset state through both WebSocket and HTTP polling.
+- No stale session or queued command survives a full reset.
+- Fifty mixed reset cycles complete without a hang, reboot, material memory decline, or unavailable session slot.
+- Recovery instructions are understandable without developer tools or a serial console.
+- The final build remains within the established firmware, heap, payload, and LittleFS budgets.
+
+### Completion action
+
+When all criteria pass, set Milestone 023 to `DONE` and append its execution record. Add later post-MVP work only after Milestone 023 without renumbering existing milestones.
diff --git a/data/app.js b/data/app.js
index b7dadd0..a59d466 100644
--- a/data/app.js
+++ b/data/app.js
@@ -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; }
diff --git a/data/index.html b/data/index.html
index ffd1367..fcc34fd 100644
--- a/data/index.html
+++ b/data/index.html
@@ -8,9 +8,13 @@
+
+
+
+
-
ESP32-C6 · локальная игра
Морской бой
+
ESP32-C6 · локальная игра
Морской бой
Подключение…
@@ -18,44 +22,53 @@
-
Подключение к игре
-
Введите имя, чтобы занять место игрока или наблюдать за партией.
+
Выбери героя
+
1 Нажми на картинку
+
+
+
+
+
+
-
Лобби
+
Готовимся к бою
Режим игры
-
+
-
+
Поле боя
+
Ждём…
+
Выберите клетку на поле соперника.
-
Выберите клетку на поле соперника.
-
Партия завершена
-
+
Партия завершена
+
Восстанавливаем связь
Состояние игры обновляется через HTTP. WebSocket подключится автоматически.