feat: add label random and animations to web interface
This commit is contained in:
@@ -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.
|
||||
|
||||
+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; }
|
||||
|
||||
+27
-14
@@ -8,9 +8,13 @@
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="game-effects" class="game-effects" aria-live="assertive" aria-atomic="true" hidden>
|
||||
<div id="effect-burst" class="effect-burst" aria-hidden="true"></div>
|
||||
<div class="effect-card"><small id="effect-badge" class="effect-badge" hidden></small><span id="effect-icon" class="effect-icon" aria-hidden="true"></span><strong id="effect-text"></strong></div>
|
||||
</div>
|
||||
<main class="app-shell">
|
||||
<header class="app-header">
|
||||
<div><p class="eyebrow">ESP32-C6 · локальная игра</p><h1>Морской бой</h1></div>
|
||||
<div><p class="eyebrow">ESP32-C6 · локальная игра</p><h1><svg class="ui-icon title-icon" aria-hidden="true"><use href="/ship-sprite.svg#ship-2-destroyer"></use></svg> Морской бой</h1></div>
|
||||
<p id="connection-status" class="connection-status" role="status">Подключение…</p>
|
||||
</header>
|
||||
<p id="notice" class="notice" aria-live="polite" hidden></p>
|
||||
@@ -18,44 +22,53 @@
|
||||
<section id="screen-connect" class="screen" aria-labelledby="connect-title">
|
||||
<div class="connection-layout">
|
||||
<div class="connection-form">
|
||||
<h2 id="connect-title">Подключение к игре</h2>
|
||||
<p>Введите имя, чтобы занять место игрока или наблюдать за партией.</p>
|
||||
<h2 id="connect-title">Выбери героя</h2>
|
||||
<p class="kid-hint"><span class="step-number" aria-hidden="true">1</span> Нажми на картинку</p>
|
||||
<div id="avatar-picker" class="avatar-picker" role="group" aria-label="Выбор героя">
|
||||
<button type="button" class="avatar-button" data-name="Капитан" aria-label="Капитан"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-captain"></use></svg><small>Капитан</small></button>
|
||||
<button type="button" class="avatar-button" data-name="Дельфин" aria-label="Дельфин"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-dolphin"></use></svg><small>Дельфин</small></button>
|
||||
<button type="button" class="avatar-button" data-name="Осьминог" aria-label="Осьминог"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-octopus"></use></svg><small>Осьминог</small></button>
|
||||
<button type="button" class="avatar-button" data-name="Акула" aria-label="Акула"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-shark"></use></svg><small>Акула</small></button>
|
||||
</div>
|
||||
<form id="join-form" class="stack-form">
|
||||
<label for="display-name">Ваше имя</label>
|
||||
<input id="display-name" name="name" type="text" maxlength="80" autocomplete="name" required>
|
||||
<div class="button-row"><button id="join-player" type="submit">Играть</button><button id="join-spectator" type="button" class="secondary">Наблюдать</button></div>
|
||||
<label for="display-name">Имя героя</label>
|
||||
<input id="display-name" name="name" type="text" maxlength="80" autocomplete="name" placeholder="Можно написать своё" required>
|
||||
<p class="kid-hint"><span class="step-number" aria-hidden="true">2</span> Нажми большую кнопку</p>
|
||||
<div class="button-row"><button id="join-player" type="submit" class="icon-button primary-action"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-gamepad"></use></svg><span>Играть</span></button><button id="join-spectator" type="button" class="secondary icon-button"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-eye"></use></svg><span>Смотреть</span></button></div>
|
||||
</form>
|
||||
</div>
|
||||
<aside class="connection-support" aria-labelledby="join-help-title">
|
||||
<h3 id="join-help-title">Как присоединиться</h3>
|
||||
<ol class="join-steps"><li>Введите имя для этой партии.</li><li>Выберите место игрока или режим наблюдателя.</li><li>Все обновления появятся сразу после подключения.</li></ol>
|
||||
<div class="support-illustration" aria-hidden="true"><svg class="ui-icon"><use href="/ship-sprite.svg#icon-wave"></use></svg><svg class="ui-icon ship-wide"><use href="/ship-sprite.svg#ship-2-destroyer"></use></svg><svg class="ui-icon"><use href="/ship-sprite.svg#icon-blast"></use></svg></div>
|
||||
<h3 id="join-help-title">Как играть</h3>
|
||||
<ol class="join-steps"><li><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-target"></use></svg> Выбери клетку.</li><li><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-blast"></use></svg> Нажми «Огонь».</li><li><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-trophy"></use></svg> Потопи все корабли.</li></ol>
|
||||
<div class="availability-panel"><p class="eyebrow">Доступные места</p><p id="availability" class="muted" aria-live="polite">Проверяем доступные места…</p></div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="screen-lobby" class="screen" aria-labelledby="lobby-title" hidden>
|
||||
<h2 id="lobby-title">Лобби</h2><p id="lobby-description"></p>
|
||||
<div class="lobby-hero"><span id="lobby-icon" class="hero-icon" aria-hidden="true"><svg class="ui-icon"><use href="/ship-sprite.svg#icon-hourglass"></use></svg></span><div><h2 id="lobby-title">Готовимся к бою</h2><p id="lobby-description"></p></div></div>
|
||||
<section id="mode-controls" class="lobby-mode-section" aria-labelledby="mode-title" hidden>
|
||||
<h3 id="mode-title">Режим игры</h3>
|
||||
<div class="mode-options" role="radiogroup" aria-labelledby="mode-title"><button type="button" role="radio" aria-checked="false" data-mode="human" class="mode-button"><span class="mode-indicator" aria-hidden="true"></span><span>Два игрока</span></button><button type="button" role="radio" aria-checked="false" data-mode="bot" class="mode-button"><span class="mode-indicator" aria-hidden="true"></span><span>Против ESP32</span></button></div>
|
||||
<div class="mode-options" role="radiogroup" aria-labelledby="mode-title"><button type="button" role="radio" aria-checked="false" data-mode="human" class="mode-button"><span class="mode-indicator" aria-hidden="true"></span><svg class="ui-icon mode-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-players"></use></svg><span>Вдвоём</span></button><button type="button" role="radio" aria-checked="false" data-mode="bot" class="mode-button"><span class="mode-indicator" aria-hidden="true"></span><svg class="ui-icon mode-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-robot"></use></svg><span>С роботом</span></button></div>
|
||||
<p id="mode-description" class="muted" aria-live="polite"></p>
|
||||
<button id="start-game" type="button" aria-describedby="mode-description">Начать игру</button>
|
||||
<button id="start-game" type="button" class="icon-button primary-action" aria-describedby="mode-description"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-rocket"></use></svg><span>Начать бой</span></button>
|
||||
</section>
|
||||
<p id="lobby-help" class="muted"></p>
|
||||
</section>
|
||||
|
||||
<section id="screen-game" class="screen" aria-labelledby="game-title" hidden>
|
||||
<div class="screen-heading"><div><h2 id="game-title">Поле боя</h2><p id="turn-status" class="turn-status"></p></div><p id="wins" class="score" aria-label="Победы"></p></div>
|
||||
<div id="action-cue" class="action-cue" role="status"><span id="action-cue-icon" class="cue-icon" aria-hidden="true"><svg class="ui-icon"><use href="/ship-sprite.svg#icon-hourglass"></use></svg></span><strong id="action-cue-text">Ждём…</strong></div>
|
||||
<div id="board-tabs" class="board-tabs" role="tablist" aria-label="Выбор поля"></div>
|
||||
<div id="shot-controls" class="shot-controls" hidden><p id="target-status" class="muted">Выберите клетку на поле соперника.</p><button id="fire-button" type="button" class="icon-button fire-button" disabled><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-blast"></use></svg><span>Огонь!</span></button><button id="cancel-target" type="button" class="secondary icon-button" disabled><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-repeat"></use></svg><span>Назад</span></button></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 abort-button" hidden>Отменить партию</button>
|
||||
</section>
|
||||
|
||||
<section id="screen-result" class="screen" aria-labelledby="result-title" hidden>
|
||||
<h2 id="result-title">Партия завершена</h2><p id="result-description"></p>
|
||||
<div id="result-boards" class="boards result-boards"></div><button id="rematch-button" type="button">Сыграть ещё</button>
|
||||
<div class="result-hero"><span id="result-icon" class="hero-icon" aria-hidden="true"><svg class="ui-icon"><use href="/ship-sprite.svg#icon-trophy"></use></svg></span><h2 id="result-title">Партия завершена</h2></div><p id="result-description"></p>
|
||||
<div id="result-boards" class="boards result-boards"></div><button id="rematch-button" type="button" class="icon-button primary-action"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-repeat"></use></svg><span>Ещё раз!</span></button>
|
||||
</section>
|
||||
<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>
|
||||
|
||||
@@ -3,4 +3,20 @@
|
||||
<symbol id="ship-2-destroyer" viewBox="0 0 96 30"><path d="M2 22 12 15h62l17 4-8 6H10zM20 15l4-7h14l5 7zM42 15l2-10h2l2 10zM50 15l2-9h6l3 9zM62 15l2-9h6l3 9zM6 19h9l-1-4H9zM79 19h9l-1-4h-5zM46 8l-4-6h2l5 6z"/></symbol>
|
||||
<symbol id="ship-3-cruiser" viewBox="0 0 144 40"><path d="M2 29 15 19h99l25 5-10 9H13zM27 19l5-10h28l7 10zM65 19l3-14h3l3 14zM78 19l3-12h9l4 12zM96 19l3-12h9l4 12zM13 23h11l-2-5h-6zM47 22h10l-1-5h-7zM112 23h11l-2-5h-7zM71 7l-6-6h3l7 6z"/></symbol>
|
||||
<symbol id="ship-4-battleship" viewBox="0 0 192 50"><path d="M2 36 18 23h135l34 7-13 11H14zM34 23l7-13h38l9 13zM84 23l4-17h5l4 17zM102 23l4-15h13l5 15zM125 23l4-15h13l5 15zM20 29h16l-2-8H24zM43 27h17l-2-8H48zM143 29h17l-2-8h-10zM163 30h18l-3-8h-11zM92 7l-8-7h4l9 7zM91 12h17v4H91z"/></symbol>
|
||||
<symbol id="icon-captain" viewBox="0 0 64 64"><path d="M13 23h38l-4-9H36l-4-7-4 7H17zm6 5a13 13 0 1 0 26 0zm-7 29c2-12 9-18 20-18s18 6 20 18z"/></symbol>
|
||||
<symbol id="icon-dolphin" viewBox="0 0 64 64"><path d="M5 38c12-18 27-25 45-19l9-7-2 13 5 7-12-2c-9 15-23 20-38 14l-8 8 2-13zm27-19-5-12 13 10zm15 10 4-2-4-2z"/></symbol>
|
||||
<symbol id="icon-octopus" viewBox="0 0 64 64"><path d="M14 30a18 18 0 1 1 36 0v9c0 7-8 9-12 4-3 7-11 7-14 0-5 6-12 2-12-4zm11-2a3 3 0 1 0 0-6 3 3 0 0 0 0 6m14 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/></symbol>
|
||||
<symbol id="icon-shark" viewBox="0 0 64 64"><path d="M4 34c13-16 29-20 46-11L60 14l-2 15 4 10-13-5C34 47 19 48 4 39l7-3zm30-11-2-13 11 10zm14 8 4-2-4-2z"/></symbol>
|
||||
<symbol id="icon-gamepad" viewBox="0 0 64 64"><path d="M16 19h32c9 0 14 23 8 29-4 4-11-5-15-9H23c-4 4-11 13-15 9-6-6-1-29 8-29m1 8v5h-5v5h5v5h5v-5h5v-5h-5v-5zm25 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8m9-6a4 4 0 1 0 0 8 4 4 0 0 0 0-8"/></symbol>
|
||||
<symbol id="icon-eye" viewBox="0 0 64 64"><path d="M3 32C14 14 24 11 32 11s18 3 29 21C50 50 40 53 32 53S14 50 3 32m19 0a10 10 0 1 0 20 0 10 10 0 0 0-20 0m6 0a4 4 0 1 0 8 0 4 4 0 0 0-8 0"/></symbol>
|
||||
<symbol id="icon-target" viewBox="0 0 64 64"><path d="M29 3h6v9a20 20 0 0 1 17 17h9v6h-9a20 20 0 0 1-17 17v9h-6v-9a20 20 0 0 1-17-17H3v-6h9a20 20 0 0 1 17-17zm3 15a14 14 0 1 0 0 28 14 14 0 0 0 0-28m0 8a6 6 0 1 0 0 12 6 6 0 0 0 0-12"/></symbol>
|
||||
<symbol id="icon-blast" viewBox="0 0 64 64"><path d="m32 2 6 17 15-9-7 16 17 4-17 6 10 15-18-7-5 18-6-18-16 9 8-17-18-4 18-6-9-15 17 8z"/></symbol>
|
||||
<symbol id="icon-hourglass" viewBox="0 0 64 64"><path d="M13 5h38v7c0 10-6 17-13 20 7 3 13 10 13 20v7H13v-7c0-10 6-17 13-20-7-3-13-10-13-20zm8 7c0 8 5 13 11 16 6-3 11-8 11-16zm0 40h22c0-8-5-13-11-16-6 3-11 8-11 16"/></symbol>
|
||||
<symbol id="icon-check" viewBox="0 0 64 64"><path d="M32 3a29 29 0 1 0 0 58 29 29 0 0 0 0-58m-5 43L13 32l6-6 8 8 18-18 6 6z"/></symbol>
|
||||
<symbol id="icon-robot" viewBox="0 0 64 64"><path d="M29 4h6v8h12a9 9 0 0 1 9 9v28a9 9 0 0 1-9 9H17a9 9 0 0 1-9-9V21a9 9 0 0 1 9-9h12zm-9 19a5 5 0 1 0 0 10 5 5 0 0 0 0-10m24 0a5 5 0 1 0 0 10 5 5 0 0 0 0-10M19 42v6h26v-6z"/></symbol>
|
||||
<symbol id="icon-players" viewBox="0 0 64 64"><path d="M20 7a10 10 0 1 0 0 20 10 10 0 0 0 0-20m24 0a10 10 0 1 0 0 20 10 10 0 0 0 0-20M3 56c1-17 8-25 17-25 6 0 10 3 12 8 2-5 6-8 12-8 9 0 16 8 17 25z"/></symbol>
|
||||
<symbol id="icon-rocket" viewBox="0 0 64 64"><path d="M42 4c10-3 17-1 18 0 1 1 3 8 0 18L39 43l-18-18zM16 30 7 33 2 47l17-8zm18 18-3 9 14 5 3-17zM18 44l-9 9 2 2 2 2 2 2 9-9zm29-29a6 6 0 1 0 0 12 6 6 0 0 0 0-12"/></symbol>
|
||||
<symbol id="icon-trophy" viewBox="0 0 64 64"><path d="M15 5h34v8h10v11c0 9-7 16-17 16-2 3-5 5-7 6v7h12v7H17v-7h12v-7c-2-1-5-3-7-6C12 40 5 33 5 24V13h10zm34 14v13c3-1 5-4 5-8v-5zM10 19v5c0 4 2 7 5 8V19z"/></symbol>
|
||||
<symbol id="icon-repeat" viewBox="0 0 64 64"><path d="M9 18h34l-7-7 7-7 19 18-19 18-7-7 7-7H9zm46 28H21l7 7-7 7L2 42l19-18 7 7-7 7h34z"/></symbol>
|
||||
<symbol id="icon-wave" viewBox="0 0 64 64"><path d="M2 25c8-8 15-8 23 0s15 8 23 0 13-7 14-6v11c-5-3-8-1-14 5-8 8-15 8-23 0s-15-8-23 0zm0 19c8-8 15-8 23 0s15 8 23 0 13-7 14-6v11c-5-3-8-1-14 5-8 8-15 8-23 0s-15-8-23 0z"/></symbol>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 975 B After Width: | Height: | Size: 4.0 KiB |
@@ -1,5 +1,6 @@
|
||||
:root { color-scheme: dark; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #061827; color: #f1f7ff; }
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body { margin: 0; min-width: 20rem; background: radial-gradient(circle at top, #104c75, #061827 45rem); }
|
||||
button, input { font: inherit; }
|
||||
button { min-height: 2.75rem; padding: .55rem .9rem; border: 0; border-radius: .65rem; background: #3ec6f0; color: #032035; font-weight: 750; cursor: pointer; }
|
||||
@@ -31,3 +32,35 @@ h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: .2rem; font-size: clamp(1.7
|
||||
#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; } }
|
||||
|
||||
.ui-icon { display: inline-block; width: 1.6em; height: 1.6em; flex: 0 0 auto; fill: currentColor; vertical-align: -.32em; } .title-icon { width: 1.25em; } .icon-button { display: inline-flex; align-items: center; justify-content: center; gap: .55rem; } .icon-button > .ui-icon { width: 1.55rem; height: 1.55rem; }
|
||||
.kid-hint { display: flex; align-items: center; gap: .5rem; margin: .8rem 0 .55rem; color: #dff6ff; font-size: 1.05rem; font-weight: 800; } .step-number { display: inline-grid; place-items: center; width: 1.7rem; height: 1.7rem; border-radius: 50%; background: #ffe56a; color: #08233d; font-size: 1rem; }
|
||||
.avatar-picker { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .55rem; margin-bottom: .85rem; }
|
||||
.avatar-button { display: grid; place-items: center; gap: .25rem; min-height: 5.4rem; padding: .45rem; border: 2px solid #41708d; background: #123b58; color: #f1f7ff; }
|
||||
.avatar-button > .ui-icon { width: clamp(2.4rem, 9vw, 3.4rem); height: clamp(2.4rem, 9vw, 3.4rem); color: #c8e9f8; } .avatar-button:nth-child(2) .ui-icon { color: #65e3ff; } .avatar-button:nth-child(3) .ui-icon { color: #d5a2ff; } .avatar-button:nth-child(4) .ui-icon { color: #a7d8e8; } .avatar-button small { max-width: 100%; overflow: hidden; font-size: .72rem; text-overflow: ellipsis; }
|
||||
.avatar-button[aria-pressed="true"] { border-color: #ffe56a; background: #176285; box-shadow: 0 0 0 .2rem rgb(255 229 106 / 25%); transform: translateY(-.12rem); }
|
||||
.support-illustration { display: flex; align-items: end; justify-content: center; gap: .8rem; margin-bottom: .8rem; color: #61d9ff; } .support-illustration .ui-icon { width: 3rem; height: 3rem; } .support-illustration .ship-wide { width: 6rem; color: #e0f3ff; }
|
||||
.join-steps { list-style: none; padding-left: 0; } .join-steps li { display: flex; align-items: center; gap: .55rem; font-weight: 700; } .join-steps .ui-icon { width: 1.5rem; height: 1.5rem; color: #ffe56a; }
|
||||
.primary-action:not(:disabled) { animation: action-pulse 1.35s ease-in-out infinite; box-shadow: 0 .35rem 1.25rem rgb(62 198 240 / 28%); }
|
||||
.lobby-hero, .result-hero { display: flex; align-items: center; gap: .85rem; } .hero-icon .ui-icon { width: clamp(2.8rem, 10vw, 4.5rem); height: clamp(2.8rem, 10vw, 4.5rem); color: #ffe56a; } .lobby-hero h2, .result-hero h2 { margin-bottom: .2rem; }
|
||||
.mode-button { grid-template-columns: 1.2rem 2.4rem minmax(0, 1fr); min-height: 4rem; } .mode-icon { width: 2rem; height: 2rem; color: #c8e9f8; }
|
||||
.action-cue { display: flex; align-items: center; justify-content: center; gap: .65rem; width: min(100%, 34rem); min-height: 3.4rem; margin: .55rem auto; padding: .55rem .9rem; border: 2px solid #41708d; border-radius: .9rem; background: #123b58; font-size: clamp(1.05rem, 4vw, 1.35rem); letter-spacing: .03em; }
|
||||
.cue-icon .ui-icon { width: 2rem; height: 2rem; } .action-cue.is-ready { border-color: #ffe56a; background: #145b4b; color: #fffbd7; animation: cue-pulse 1.1s ease-in-out infinite; } .action-cue.has-target { border-color: #ff9d3d; background: #6a3d14; }
|
||||
.board.is-action { border-color: #ffe56a; box-shadow: 0 0 0 .15rem rgb(255 229 106 / 25%), 0 .65rem 1.5rem rgb(0 0 0 / 12%) !important; }
|
||||
.board.is-action .cell.target:nth-child(3n) { animation: target-ripple 1.8s ease-in-out infinite; }
|
||||
.fire-button:not(:disabled) { background: #ff9d3d; color: #2d1600; animation: fire-pulse .85s ease-in-out infinite; }
|
||||
.shot-controls { padding: .65rem; border: 1px solid #315a75; border-radius: .8rem; background: rgb(7 31 50 / 94%); }
|
||||
.game-effects { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; overflow: hidden; pointer-events: none; }
|
||||
.game-effects[hidden] { display: none; } .effect-card { position: relative; z-index: 2; display: grid; place-items: center; min-width: min(84vw, 20rem); padding: 1.25rem; border: .2rem solid #fff; border-radius: 1.4rem; background: rgb(5 25 42 / 92%); color: #fff; font-size: clamp(1.35rem, 7vw, 2.25rem); text-align: center; box-shadow: 0 1.5rem 5rem #000; animation: reward-pop .9s cubic-bezier(.2, 1.5, .5, 1) both; }
|
||||
.effect-badge { margin-bottom: .35rem; padding: .3rem .75rem; border-radius: 99rem; background: #ffe56a; color: #08233d; font-size: clamp(.85rem, 4vw, 1.15rem); font-weight: 950; letter-spacing: .08em; transform: rotate(-3deg); animation: badge-slam .5s cubic-bezier(.15, 1.7, .35, 1) both; } .effect-badge[hidden] { display: none; }
|
||||
.effect-icon .ui-icon { width: clamp(5rem, 24vw, 9rem); height: clamp(5rem, 24vw, 9rem); color: #ffe56a; filter: drop-shadow(0 .3rem .5rem rgb(0 0 0 / 35%)); }
|
||||
.game-effects.effect-hit { background: rgb(255 174 0 / 20%); } .game-effects.effect-damage { background: rgb(255 20 20 / 38%); animation: screen-danger .55s ease-in-out 2; } .game-effects.effect-miss { background: rgb(39 186 255 / 17%); } .game-effects.effect-victory { background: rgb(255 220 44 / 20%); }
|
||||
.effect-burst span { position: absolute; left: 50%; top: 50%; font-size: clamp(1.4rem, 6vw, 2.8rem); animation: burst-away 1.2s ease-out both; animation-delay: var(--delay); transform: rotate(var(--rotate)); }
|
||||
.burst-bubbles span { color: #8eeaff; animation-name: bubble-away; } .burst-sparks span { color: #ff9d3d; animation-duration: .8s; } .burst-stars span { color: #ffe56a; }
|
||||
.motion-bounce .effect-card { animation-name: reward-bounce; } .motion-spin .effect-card { animation-name: reward-spin; } .motion-swoop .effect-card { animation-name: reward-swoop; } .motion-zoom .effect-card { animation-name: reward-zoom; } .motion-combo .effect-card { animation: reward-combo 1s cubic-bezier(.15, 1.45, .35, 1) both; }
|
||||
.combo-power { background: radial-gradient(circle, rgb(255 229 106 / 42%), rgb(255 68 0 / 20%) 45%, rgb(0 0 0 / 8%)); } .combo-power .effect-card { border-color: #ffe56a; box-shadow: 0 0 2rem #ff9d3d, 0 1.5rem 5rem #000; } .combo-power .effect-icon { animation: combo-icon .32s ease-in-out 3 alternate; }
|
||||
body.fx-damage .app-shell { animation: ship-shake .5s ease-in-out 2; } body.fx-hit .app-shell { animation: screen-glow .75s ease-out; }
|
||||
@keyframes action-pulse { 50% { filter: brightness(1.16); transform: scale(1.025); } } @keyframes cue-pulse { 50% { box-shadow: 0 0 0 .35rem rgb(255 229 106 / 18%); transform: scale(1.015); } } @keyframes target-ripple { 50% { filter: brightness(1.35); } } @keyframes fire-pulse { 50% { box-shadow: 0 0 0 .35rem rgb(255 157 61 / 25%); transform: scale(1.035); } }
|
||||
@keyframes reward-pop { 0% { opacity: 0; transform: scale(.35) rotate(-8deg); } 45% { opacity: 1; transform: scale(1.12) rotate(3deg); } 100% { transform: scale(1) rotate(0); } } @keyframes reward-bounce { 0% { opacity: 0; transform: translateY(55vh) scale(.8); } 55% { opacity: 1; transform: translateY(-1.2rem) scale(1.06); } 75% { transform: translateY(.5rem) scale(.98); } 100% { transform: none; } } @keyframes reward-spin { 0% { opacity: 0; transform: scale(.2) rotate(-210deg); } 65% { opacity: 1; transform: scale(1.12) rotate(12deg); } 100% { transform: none; } } @keyframes reward-swoop { 0% { opacity: 0; transform: translateX(-110vw) rotate(-14deg); } 65% { opacity: 1; transform: translateX(1rem) rotate(2deg); } 100% { transform: none; } } @keyframes reward-zoom { 0% { opacity: 0; transform: scale(2.4); filter: blur(.5rem); } 65% { opacity: 1; transform: scale(.92); filter: blur(0); } 100% { transform: scale(1); } } @keyframes reward-combo { 0% { opacity: 0; transform: scale(.1) rotate(-18deg); } 35% { opacity: 1; transform: scale(1.28) rotate(7deg); } 58% { transform: scale(.9) rotate(-3deg); } 78% { transform: scale(1.08) rotate(1deg); } 100% { transform: none; } } @keyframes badge-slam { from { opacity: 0; transform: translateY(-4rem) scale(2) rotate(8deg); } } @keyframes combo-icon { to { transform: scale(1.18) rotate(8deg); filter: brightness(1.35); } } @keyframes screen-danger { 50% { background: rgb(255 20 20 / 58%); } } @keyframes ship-shake { 20%, 60% { transform: translateX(-.45rem); } 40%, 80% { transform: translateX(.45rem); } } @keyframes screen-glow { 50% { filter: brightness(1.28) saturate(1.25); } } @keyframes burst-away { to { opacity: 0; translate: var(--x) var(--y); rotate: var(--spin); scale: .7; } } @keyframes bubble-away { to { opacity: 0; translate: var(--x) calc(var(--y) - 22vh); scale: 1.7; } }
|
||||
@media (max-width: 43.99rem) { .app-shell { padding-top: .75rem; } .app-header .eyebrow { display: none; } .app-header h1 { font-size: 1.65rem; } .avatar-picker { gap: .35rem; } .avatar-button { min-height: 4.8rem; padding-inline: .2rem; } .connection-support { display: none; } #screen-game .shot-controls { grid-template-columns: 1fr 1fr; position: sticky; z-index: 8; top: .25rem; margin-bottom: .6rem; box-shadow: 0 .5rem 1.4rem rgb(0 0 0 / 35%); } #screen-game .shot-controls p { grid-column: 1 / -1; text-align: center; } #screen-game #fire-button, #screen-game #cancel-target { min-height: 3.4rem; } }
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; } }
|
||||
|
||||
@@ -43,7 +43,50 @@
|
||||
};
|
||||
}
|
||||
|
||||
const api = { createTargetActivator };
|
||||
function detectFeedback(previous, next, viewer) {
|
||||
if (!previous || !next || previous.gameId !== next.gameId || next.version <= previous.version) return undefined;
|
||||
const player = viewer === 'player1' ? 0 : viewer === 'player2' ? 1 : -1;
|
||||
if (previous.phase !== next.phase && next.phase === 'finished') {
|
||||
if (player < 0) return { type: 'finish' };
|
||||
return { type: next.winner === player ? 'victory' : 'defeat' };
|
||||
}
|
||||
if (previous.phase !== 'in_progress' && next.phase === 'in_progress') return { type: 'start' };
|
||||
if (!Array.isArray(previous.boards) || !Array.isArray(next.boards)) return undefined;
|
||||
|
||||
let best;
|
||||
for (let board = 0; board < 2; board += 1) {
|
||||
if (typeof previous.boards[board] !== 'string' || typeof next.boards[board] !== 'string') continue;
|
||||
for (let cell = 0; cell < 100; cell += 1) {
|
||||
const before = previous.boards[board][cell];
|
||||
const after = next.boards[board][cell];
|
||||
if (before === after) continue;
|
||||
const perspective = player < 0 ? 'watch' : board === player ? 'damage' : 'attack';
|
||||
if (after === '4') best = { type: perspective === 'damage' ? 'sunk-damage' : perspective === 'attack' ? 'sunk' : 'watch-sunk' };
|
||||
else if (!best && after === '3') best = { type: perspective === 'damage' ? 'damage' : perspective === 'attack' ? 'hit' : 'watch-hit' };
|
||||
else if (!best && after === '2') best = { type: perspective === 'attack' ? 'miss' : perspective === 'damage' ? 'dodged' : 'watch-miss' };
|
||||
}
|
||||
}
|
||||
if (best) return best;
|
||||
if (player >= 0 && previous.turn !== next.turn && next.turn === viewer) return { type: 'turn' };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createReactionPicker(catalog, random = Math.random) {
|
||||
const previousByType = new Map();
|
||||
return type => {
|
||||
const variants = catalog[type];
|
||||
if (!Array.isArray(variants) || variants.length === 0) return undefined;
|
||||
if (variants.length === 1) return variants[0];
|
||||
const previous = previousByType.get(type);
|
||||
const candidateCount = previous === undefined ? variants.length : variants.length - 1;
|
||||
let index = Math.min(candidateCount - 1, Math.max(0, Math.floor(random() * candidateCount)));
|
||||
if (previous !== undefined && index >= previous) index += 1;
|
||||
previousByType.set(type, index);
|
||||
return variants[index];
|
||||
};
|
||||
}
|
||||
|
||||
const api = { createTargetActivator, detectFeedback, createReactionPicker };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else globalThis.BattleshipTargetInteraction = api;
|
||||
})();
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(env.subst("$PROJECT_DIR"))
|
||||
DATA = ROOT / "data"
|
||||
ASSETS = ("index.html", "styles.css", "app.js", "ship-sprite.svg")
|
||||
ASSETS = ("index.html", "styles.css", "app.js", "target_interaction.js", "ship-sprite.svg")
|
||||
|
||||
|
||||
def minify_html(source):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { createTargetActivator } = require('../../data/target_interaction.js');
|
||||
const { createTargetActivator, detectFeedback, createReactionPicker } = require('../../data/target_interaction.js');
|
||||
|
||||
test('a single tap selects a target without firing', () => {
|
||||
const selected = [];
|
||||
@@ -47,3 +47,42 @@ test('out-of-turn or already-targeted cells cannot select or fire', () => {
|
||||
assert.equal(selected, 0);
|
||||
assert.equal(shots, 0);
|
||||
});
|
||||
|
||||
function gameState(version, boards, overrides = {}) {
|
||||
return { version, gameId: 7, phase: 'in_progress', turn: 'player1', winner: null, boards, ...overrides };
|
||||
}
|
||||
|
||||
test('feedback distinguishes a hit on the opponent from damage to the player', () => {
|
||||
const water = '0'.repeat(100);
|
||||
const opponentHit = `${water.slice(0, 12)}3${water.slice(13)}`;
|
||||
assert.equal(detectFeedback(gameState(1, [water, water]), gameState(2, [water, opponentHit]), 'player1').type, 'hit');
|
||||
assert.equal(detectFeedback(gameState(1, [water, water]), gameState(2, [opponentHit, water]), 'player1').type, 'damage');
|
||||
});
|
||||
|
||||
test('sinking and victory feedback take priority over ordinary cell changes', () => {
|
||||
const water = '0'.repeat(100);
|
||||
const sunk = `44${water.slice(2)}`;
|
||||
assert.equal(detectFeedback(gameState(1, [water, water]), gameState(2, [water, sunk]), 'player1').type, 'sunk');
|
||||
const finished = gameState(3, [water, sunk], { phase: 'finished', winner: 0 });
|
||||
assert.equal(detectFeedback(gameState(2, [water, water]), finished, 'player1').type, 'victory');
|
||||
assert.equal(detectFeedback(gameState(2, [water, water]), finished, 'player2').type, 'defeat');
|
||||
});
|
||||
|
||||
test('duplicate snapshots do not repeat rewards', () => {
|
||||
const water = '0'.repeat(100);
|
||||
assert.equal(detectFeedback(gameState(2, [water, water]), gameState(2, [water, water]), 'player1'), undefined);
|
||||
});
|
||||
|
||||
test('reaction picker avoids an immediate repeat for the same event', () => {
|
||||
const picker = createReactionPicker({ hit: ['one', 'two', 'three'] }, () => 0);
|
||||
assert.equal(picker('hit'), 'one');
|
||||
assert.equal(picker('hit'), 'two');
|
||||
assert.equal(picker('hit'), 'one');
|
||||
});
|
||||
|
||||
test('reaction picker handles single and missing catalogs', () => {
|
||||
const picker = createReactionPicker({ miss: ['splash'] }, () => 0.9);
|
||||
assert.equal(picker('miss'), 'splash');
|
||||
assert.equal(picker('miss'), 'splash');
|
||||
assert.equal(picker('unknown'), undefined);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user