feat(session): add explicit leave and immediate slot release
This commit is contained in:
@@ -351,6 +351,13 @@ visible edge and an adjacent server-approved self-cell move across that exact
|
||||
edge; it is bounded, replaceable by a newer projection, and never delays the
|
||||
received state.
|
||||
|
||||
The browser may keep one low-priority heartbeat in flight after a session is
|
||||
established. It measures complete round trip with its own monotonic clock; the
|
||||
matching acknowledgement contains only a bounded ESP-monotonic handling
|
||||
interval. The displayed communication remainder is `max(0, round trip - ESP
|
||||
handling)` and is never represented as a synchronized-clock measurement or
|
||||
pure Wi-Fi diagnosis.
|
||||
|
||||
Player projections are translated only within their received cells so the
|
||||
authoritative `self` cell aligns with the responsive local focal point. That
|
||||
point is centered horizontally and 30% down the usable portrait map region;
|
||||
|
||||
@@ -3265,7 +3265,7 @@ indicator.
|
||||
|
||||
## Milestone 029 — Low-overhead live connection quality
|
||||
|
||||
**Status:** `NOT_STARTED`
|
||||
**Status:** `COMPLETE`
|
||||
|
||||
### Objective
|
||||
|
||||
@@ -3412,5 +3412,205 @@ resources.
|
||||
measured ESP handling interval and an honestly labelled communication
|
||||
remainder, while keeping the displayed headline value equal to the complete
|
||||
browser-to-ESP application round trip.
|
||||
- Implementation and verification have not started, as requested by the
|
||||
project owner.
|
||||
- On 2026-09-13, implementation began with a single in-flight browser
|
||||
heartbeat sampled no more often than every five seconds, a five-sample
|
||||
median, 15-second stale timeout, and explicit unknown, stale, and offline
|
||||
indicator states. Heartbeat acknowledgements now include bounded ESP-only
|
||||
`handlingMs`, and the browser derives the non-negative `Связь` remainder.
|
||||
- On 2026-09-13, `node --test test/web/test_player_app.js
|
||||
test/web/test_hedge_svg.js` passed all 33 tests, including bounded
|
||||
heartbeat correlation, malformed-field rejection, smoothing, stale state,
|
||||
indicator copy, and compact header placement.
|
||||
- On 2026-09-13, `bash scripts/run_host_tests.sh` passed. `python3
|
||||
scripts/verify_web_assets.py` passed with a deterministic 32,503 /
|
||||
1,572,864 byte compressed payload. `/home/sasa/.platformio/penv/bin/pio
|
||||
run` passed with firmware at 1,029,808 / 2,097,152 bytes and
|
||||
`/home/sasa/.platformio/penv/bin/pio run --target buildfs` passed with a
|
||||
2,031,616 / 2,031,616 byte LittleFS image.
|
||||
- Physical-device latency measurements and mobile and desktop visual
|
||||
acceptance were pending until the project-owner check below.
|
||||
- On 2026-09-13, the project owner manually checked the quality indicator and
|
||||
accepted `ESP: 0 мс` as the correct rounded display for the measured
|
||||
sub-millisecond heartbeat handling interval. The project owner found no UI
|
||||
issue and accepted the indicator on the available application screens.
|
||||
|
||||
## Milestone 030 — Explicit session exit and slot release
|
||||
|
||||
**Status:** `IN_PROGRESS`
|
||||
|
||||
### Objective
|
||||
|
||||
Let an authenticated player or observer deliberately leave through the
|
||||
top-right settings menu, revoke only that browser's session, immediately free
|
||||
its slot, and return it to the name-entry screen with its previous display name
|
||||
prefilled. A player leaving an unfinished match must authoritatively cancel
|
||||
that match and return every remaining participant to the lobby, while an
|
||||
observer departure or a lobby departure must not reset gameplay.
|
||||
|
||||
### Scope
|
||||
|
||||
- Do not start implementation until every preceding milestone is `COMPLETE`.
|
||||
Milestone 029 is `COMPLETE` when this plan is created; this milestone remains
|
||||
`NOT_STARTED` because implementation was not requested.
|
||||
- Add one clearly labelled Russian `Выйти из игры` action to the existing
|
||||
gear-triggered settings menu. Show it only while the browser owns an
|
||||
authenticated player or observer session; do not show it on the unauthenticated
|
||||
welcome and name-entry screen.
|
||||
- Require a modal confirmation before sending an exit command. For a player in
|
||||
countdown, active, finish-pending, or paused state, clearly warn that the
|
||||
current match will end for everyone and the player's slot will be freed. For
|
||||
a player in the lobby or an observer in any phase, explain that only this
|
||||
browser's place will be released.
|
||||
- Treat opening or cancelling the confirmation as browser-local UI only. While
|
||||
it is open, suppress input from that browser without pausing or otherwise
|
||||
mutating authoritative state; cancelling must restore the previous screen,
|
||||
focus, controls, and session unchanged.
|
||||
- Introduce a versioned authenticated explicit-leave protocol command and a
|
||||
correlated success response. It must be distinct from an unexpected socket
|
||||
disconnect, page reload, or transient connection loss, which retain the
|
||||
existing 60-second player reconnection contract.
|
||||
- On an accepted player leave during countdown, active, finish-pending, or
|
||||
paused state, stop all match timers, clear the current authoritative match,
|
||||
return the lobby to its waiting state, retain every other connected session
|
||||
and role, revoke the leaving session, free its player slot immediately, and
|
||||
broadcast the cancellation and fresh lobby state to the remaining players
|
||||
and observers.
|
||||
- On an accepted player leave while already in the waiting lobby or after a
|
||||
completed result, revoke only that player's session and free its slot. Do not
|
||||
reset an unfinished match because none is active in those phases.
|
||||
- On an accepted observer leave in any phase, revoke only that observer's
|
||||
session and free its observer slot. Never pause, reset, finish, restart, or
|
||||
otherwise mutate the match because an observer leaves.
|
||||
- Preserve the roles and sessions of all participants who did not leave. If
|
||||
Player 1 leaves, Player 2 remains in its existing slot and a later player may
|
||||
claim the newly free Player 1 slot and become the lobby owner under the
|
||||
existing ownership rule.
|
||||
- Invalidate the departed session token on the ESP before confirming success.
|
||||
The revoked token must never restore its former role, slot, position,
|
||||
inventory, or reconnect reservation, and a duplicate or replayed leave must
|
||||
be harmless.
|
||||
- After confirmed success, remove the session token from browser-local storage,
|
||||
clear all session, match, input, timer, heartbeat, animation, and pending
|
||||
command state, and show the welcome/name-entry screen without requiring a
|
||||
page reload.
|
||||
- Store the last successfully used display name separately from the temporary
|
||||
session token in a bounded browser-local value. Prefill it on return to the
|
||||
name-entry screen, but still apply normal name validation and duplicate-name
|
||||
rejection if the user tries to join again.
|
||||
- Do not claim that the slot was released before the ESP acknowledges the
|
||||
explicit leave. If the command cannot be delivered or confirmed, show a
|
||||
concise Russian error and retain honest reconnect behavior rather than
|
||||
silently turning a network failure into a successful exit.
|
||||
- Bound command size, correlation state, acknowledgement waiting, and cleanup.
|
||||
The leave path must not disclose hidden maze state, permit one participant to
|
||||
revoke another session, bypass observer read-only gameplay rules, or create
|
||||
a new unbounded queue or retry loop.
|
||||
- Preserve the compact header, live connection-quality indicator, Wi-Fi and
|
||||
preference actions, responsive layouts, offline operation, Russian
|
||||
player-facing copy, and firmware and LittleFS budgets.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Updated explicit-leave, match-cancellation, slot-release, session-revocation,
|
||||
and reconnect contracts in `MVP.md`, `ARCHITECTURE.md`, `docs/PROTOCOL.md`,
|
||||
`docs/UI_STYLE.md`, and `docs/PLAYER_UI.md`.
|
||||
- A bounded authenticated leave command and response implemented across the
|
||||
protocol parser, lobby, authoritative game lifecycle, gateway, and browser.
|
||||
- A settings-menu exit action with role- and phase-appropriate confirmation,
|
||||
failure handling, focus restoration, and successful return to the
|
||||
name-entry screen.
|
||||
- Separate bounded persistence for the last player display name and complete
|
||||
cleanup of the revoked temporary session.
|
||||
- Deterministic protocol, lobby, gateway, game-lifecycle, browser,
|
||||
accessibility, race, replay, reconnect, responsive-layout, and regression
|
||||
tests.
|
||||
- Host, browser, web-asset, firmware, LittleFS, maximum-session, and
|
||||
physical-device verification evidence.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. An authenticated player or observer sees exactly one `Выйти из игры` item
|
||||
in the gear menu on every applicable screen. An unauthenticated browser sees
|
||||
no exit item, and primary game controls remain outside the menu.
|
||||
2. Selecting the item opens a keyboard- and touch-accessible confirmation
|
||||
modal and sends no protocol command until explicit confirmation. Cancelling,
|
||||
pressing Escape, or activating the modal's cancel action leaves the session
|
||||
and authoritative state unchanged and safely restores focus.
|
||||
3. A player confirmation during countdown, active, finish-pending, or paused
|
||||
state explicitly says that the match will end for everyone and the slot will
|
||||
be freed. Confirmation produces exactly one authoritative cancellation,
|
||||
stops every relevant timer, clears the current match, and returns all
|
||||
remaining connected participants to a fresh waiting lobby.
|
||||
4. The leaving player's session is removed and its slot becomes available in
|
||||
the same accepted server transition. Every other player's and observer's
|
||||
session, role, and connection remain valid, and the resulting lobby snapshot
|
||||
consistently shows the newly free slot.
|
||||
5. A player leaving the waiting lobby or a completed result releases only that
|
||||
player's session and slot. It does not disconnect other participants or
|
||||
perform an additional match reset.
|
||||
6. An observer leaving during any phase releases only that observer session and
|
||||
observer slot. Match phase, players, timers, maze, objects, inventory, and
|
||||
results remain byte-for-byte unchanged except for the expected lobby
|
||||
connection revision or participant count.
|
||||
7. The explicit leave command authenticates the sending session and cannot
|
||||
name or revoke another token or slot. Duplicate, stale, reordered, malformed,
|
||||
unauthorized, and replayed requests cause no second cancellation, no extra
|
||||
slot release, and no unrelated state mutation.
|
||||
8. An accepted explicit leave never starts the 60-second reconnect reservation.
|
||||
A normal page reload, accidental socket loss, or unconfirmed exit continues
|
||||
to follow the existing reconnect and pause rules without behavioral change.
|
||||
9. Only after the correlated success response, the departing browser deletes
|
||||
its temporary session token, clears all role and gameplay state, stops all
|
||||
input, animation, heartbeat, reconnect, and pending-command work, and shows
|
||||
the welcome/name-entry screen without a full reload or stale map flash.
|
||||
10. The last successfully used display name survives separately from the
|
||||
revoked token and is prefilled in the name field after exit and on a later
|
||||
visit. It is bounded to the existing name limit, contains no token data,
|
||||
and receives the normal validation and uniqueness checks when reused.
|
||||
11. If the leave request cannot be sent, times out, loses its socket, or receives
|
||||
a rejection, the browser does not report a successful release. It presents
|
||||
concise Russian recovery information, retains bounded state, and neither
|
||||
retries indefinitely nor corrupts a later reconnect or successful leave.
|
||||
12. If Player 1 leaves, Player 2 remains in Player 2's slot and a new player can
|
||||
immediately claim the free Player 1 slot, becoming the lobby owner and
|
||||
gaining start authority only through the existing server rule. Equivalent
|
||||
behavior applies when Player 2 leaves.
|
||||
13. The exit item and confirmation fit supported narrow portrait,
|
||||
compact-landscape, tablet, and desktop layouts, respect safe areas, avoid
|
||||
page and map overflow, and remain distinguishable without relying only on
|
||||
color, sound, vibration, or animation.
|
||||
14. Automated tests cover every role and phase, confirmation and cancellation,
|
||||
authoritative match cancellation, retained participants, immediate slot
|
||||
reuse, token revocation, remembered-name prefill, disconnect races,
|
||||
idempotence, authorization, cleanup, accessibility, and responsive layout.
|
||||
15. All host and browser tests pass; compressed-web-asset validation passes;
|
||||
the payload remains within the 1.5 MiB web budget and LittleFS partition;
|
||||
and firmware remains within the factory partition.
|
||||
16. Physical-device testing confirms that a player can cancel an unfinished
|
||||
match and immediately release its slot, an observer can leave without
|
||||
affecting play, the remaining browsers receive the correct lobby state,
|
||||
and the departing browser returns to a prefilled name-entry screen without
|
||||
stale controls, delayed input, or reconnect side effects.
|
||||
17. The project owner confirms on representative mobile and desktop browsers
|
||||
that the exit action is easy to find but difficult to trigger accidentally,
|
||||
its consequences are clear, and all role-specific outcomes match the
|
||||
intended behavior, or explicitly approves a documented evidence deviation.
|
||||
|
||||
### Evidence
|
||||
|
||||
- On 2026-09-13, planning inspection confirmed that unexpected player socket
|
||||
loss currently pauses an active match and reserves the player session for a
|
||||
60-second reconnect, while observer disconnect does not affect gameplay.
|
||||
Therefore immediate voluntary slot release requires an explicit authenticated
|
||||
lifecycle command rather than client-only token deletion or socket closure.
|
||||
- On 2026-09-13, the project owner required a settings-menu exit action with a
|
||||
confirmation modal, complete session-token revocation, immediate slot
|
||||
release, and return to the name-entry screen with the previous player name
|
||||
prefilled.
|
||||
- The project owner also required an unfinished match to be cancelled for all
|
||||
participants when a player leaves, while a lobby player or observer departure
|
||||
must affect only the departing session.
|
||||
- On 2026-09-13, implementation began with an authenticated `leave` command,
|
||||
immediate server-side session revocation, player-match cancellation, and a
|
||||
browser confirmation flow that clears the temporary token only after the
|
||||
correlated acknowledgement.
|
||||
|
||||
@@ -66,6 +66,8 @@ maze32_lobby_result_t maze32_lobby_join_observer(maze32_lobby_t* lobby, int conn
|
||||
maze32_lobby_result_t maze32_lobby_restore(maze32_lobby_t* lobby, int connection_id,
|
||||
const char* token, maze32_session_t** session);
|
||||
void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id);
|
||||
int maze32_lobby_leave(maze32_lobby_t* lobby, int connection_id, maze32_role_t* role,
|
||||
unsigned int* player_slot);
|
||||
maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id, uint64_t now_ms);
|
||||
int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms);
|
||||
void maze32_lobby_reset_match(maze32_lobby_t* lobby);
|
||||
|
||||
@@ -22,6 +22,7 @@ typedef enum {
|
||||
MAZE32_MESSAGE_MOVE,
|
||||
MAZE32_MESSAGE_DROP_KEY,
|
||||
MAZE32_MESSAGE_RESTART,
|
||||
MAZE32_MESSAGE_LEAVE,
|
||||
MAZE32_MESSAGE_HEARTBEAT,
|
||||
} maze32_message_type_t;
|
||||
|
||||
|
||||
@@ -167,6 +167,30 @@ void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id) {
|
||||
}
|
||||
}
|
||||
|
||||
int maze32_lobby_leave(maze32_lobby_t* lobby, int connection_id, maze32_role_t* role,
|
||||
unsigned int* player_slot) {
|
||||
if (lobby == NULL) {
|
||||
return 0;
|
||||
}
|
||||
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||
maze32_session_t* session = &lobby->sessions[index];
|
||||
if (session->role != MAZE32_ROLE_NONE && session->connected &&
|
||||
session->connection_id == connection_id) {
|
||||
if (role != NULL) {
|
||||
*role = session->role;
|
||||
}
|
||||
if (player_slot != NULL) {
|
||||
*player_slot = session->player_slot;
|
||||
}
|
||||
memset(session, 0, sizeof(*session));
|
||||
session->connection_id = -1;
|
||||
lobby->revision++;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id,
|
||||
uint64_t now_ms) {
|
||||
if (lobby == NULL || lobby->phase != MAZE32_LOBBY_WAITING) {
|
||||
|
||||
@@ -193,6 +193,9 @@ static maze32_message_type_t message_type(const char* value) {
|
||||
if (strcmp(value, "restart") == 0) {
|
||||
return MAZE32_MESSAGE_RESTART;
|
||||
}
|
||||
if (strcmp(value, "leave") == 0) {
|
||||
return MAZE32_MESSAGE_LEAVE;
|
||||
}
|
||||
if (strcmp(value, "heartbeat") == 0) {
|
||||
return MAZE32_MESSAGE_HEARTBEAT;
|
||||
}
|
||||
|
||||
@@ -948,6 +948,40 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
||||
"Сначала выберите роль или восстановите сессию");
|
||||
return;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_LEAVE) {
|
||||
maze32_role_t leaving_role = session->role;
|
||||
int cancel_match = leaving_role == MAZE32_ROLE_PLAYER &&
|
||||
gateway->lobby.phase != MAZE32_LOBBY_WAITING &&
|
||||
gateway->game.phase != MAZE32_GAME_PHASE_FINISHED;
|
||||
char acknowledgement[192] = "";
|
||||
size_t length = 0U;
|
||||
char prefix[96];
|
||||
int written = snprintf(prefix, sizeof(prefix),
|
||||
"{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32 ",\"requestId\":",
|
||||
next_server_sequence(gateway));
|
||||
if (written >= 0 && (size_t)written < sizeof(prefix) &&
|
||||
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix) &&
|
||||
append_json_string(acknowledgement, sizeof(acknowledgement), &length,
|
||||
message->request_id) &&
|
||||
append_text(acknowledgement, sizeof(acknowledgement), &length,
|
||||
",\"payload\":{\"command\":\"leave\"}}")) {
|
||||
enqueue(gateway, socket_fd, acknowledgement);
|
||||
}
|
||||
if (cancel_match) {
|
||||
esp_timer_stop(gateway->countdown_timer);
|
||||
esp_timer_stop(gateway->reconnect_timer);
|
||||
esp_timer_stop(gateway->finish_timer);
|
||||
initialize_game(gateway);
|
||||
maze32_lobby_reset_match(&gateway->lobby);
|
||||
}
|
||||
maze32_lobby_leave(&gateway->lobby, socket_fd, NULL, NULL);
|
||||
if (cancel_match) {
|
||||
broadcast_match_event(gateway, "reset", "{}");
|
||||
}
|
||||
broadcast_snapshot(gateway);
|
||||
drain_queue(gateway);
|
||||
return;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_START) {
|
||||
if (session->role == MAZE32_ROLE_OBSERVER) {
|
||||
ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=start", socket_fd);
|
||||
@@ -977,18 +1011,29 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
||||
return;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_HEARTBEAT) {
|
||||
int64_t heartbeat_started_us = esp_timer_get_time();
|
||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
|
||||
size_t length = 0U;
|
||||
char prefix[128];
|
||||
char handling_text[32];
|
||||
int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32,
|
||||
next_server_sequence(gateway));
|
||||
if (written >= 0 && (size_t)written < sizeof(prefix) &&
|
||||
uint64_t handling_ms = (uint64_t)(esp_timer_get_time() - heartbeat_started_us) / 1000U;
|
||||
if (handling_ms > 1000U) {
|
||||
handling_ms = 1000U;
|
||||
}
|
||||
int handling_written =
|
||||
snprintf(handling_text, sizeof(handling_text), "%" PRIu64, handling_ms);
|
||||
if (written >= 0 && (size_t)written < sizeof(prefix) && handling_written >= 0 &&
|
||||
(size_t)handling_written < sizeof(handling_text) &&
|
||||
append_text(response, sizeof(response), &length, prefix) &&
|
||||
append_text(response, sizeof(response), &length, ",\"requestId\":") &&
|
||||
append_json_string(response, sizeof(response), &length, message->request_id) &&
|
||||
append_text(response, sizeof(response), &length,
|
||||
",\"payload\":{\"command\":\"heartbeat\"}}")) {
|
||||
enqueue(gateway, socket_fd, response);
|
||||
",\"payload\":{\"command\":\"heartbeat\",\"handlingMs\":") &&
|
||||
append_text(response, sizeof(response), &length, handling_text) &&
|
||||
append_text(response, sizeof(response), &length, "}}")) {
|
||||
enqueue_with_policy(gateway, socket_fd, response, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -14,6 +14,7 @@ browser timestamp from an ESP timestamp.
|
||||
| command to painted view | browser | `move` send to next frame after matching view render |
|
||||
| handler and send | ESP | existing `/diagnostics` maxima |
|
||||
| network round trip | client | ICMP and WebSocket heartbeat samples |
|
||||
| ESP heartbeat handling | ESP | bounded acknowledgement preparation interval |
|
||||
|
||||
The accepted command acknowledgement contains the authoritative `stateSequence`.
|
||||
The browser records a sample only after the complete view with that sequence is
|
||||
@@ -38,6 +39,10 @@ rendered and the next animation frame occurs. Samples are local-only, capped at
|
||||
and send maxima, queue high-water marks, observer view chunk count, and
|
||||
ICMP/WebSocket round trips. Attribute a delay only when the matching
|
||||
interval is elevated.
|
||||
7. For the compact quality indicator, record the rounded `Отклик`, `Связь`,
|
||||
and `ESP` values after the five-second heartbeat cadence has collected a
|
||||
fresh sample. `Связь` is the non-negative round-trip remainder, not a pure
|
||||
Wi-Fi measurement.
|
||||
|
||||
## Safety checks
|
||||
|
||||
|
||||
@@ -66,6 +66,12 @@ closing this presentation-only menu never sends a command or changes server
|
||||
state. Escape and outside activation dismiss it; player actions also close it
|
||||
before opening their existing destination.
|
||||
|
||||
The header response indicator takes at most one heartbeat sample every five
|
||||
seconds, holds one in-flight request, and uses a five-sample median. Good is
|
||||
at most 150 ms, delayed is 151--500 ms, and slow is above 500 ms. Hysteresis
|
||||
holds good until above 175 ms and slow until at most 450 ms; a sample is stale
|
||||
after 15 seconds. Hidden documents and closed sockets do not probe.
|
||||
|
||||
The browser stores the direction for each outstanding move request. A matching
|
||||
server `blocked` rejection may include only that player's approved breakthrough
|
||||
stage, which bends the already rendered bush wall for at most 340 ms without
|
||||
|
||||
+7
-1
@@ -39,6 +39,9 @@ is only echoed for correlation; it grants no authority.
|
||||
- `restart`: `{}`. Only connected Player 1 may reset a finished match, and both
|
||||
players must be connected. Sessions are preserved while a new authoritative
|
||||
maze and game state are created.
|
||||
- `leave`: `{}`. Revokes only the authenticated sending session. A player
|
||||
leaving an unfinished match cancels it for all participants; observer and
|
||||
waiting-lobby departures release only that session.
|
||||
- `heartbeat`: `{}` after a session has been joined or restored.
|
||||
|
||||
## Server messages
|
||||
@@ -60,7 +63,10 @@ is only echoed for correlation; it grants no authority.
|
||||
- `match_result`: reports the terminal winner or draw.
|
||||
- `reset`: reports the authoritative return from a finished match to the
|
||||
waiting lobby.
|
||||
- `ack`: successful heartbeat correlation.
|
||||
- `ack`: successful heartbeat correlation. A heartbeat acknowledgement has
|
||||
`payload.command` set to `heartbeat` and a bounded integer
|
||||
`payload.handlingMs` measured only by the ESP monotonic clock while it
|
||||
prepares that acknowledgement.
|
||||
- `error`: a stable English `code` and Russian player-facing `message`.
|
||||
|
||||
An observer session can receive snapshots but cannot issue `start`, `move`, or
|
||||
|
||||
@@ -106,6 +106,15 @@ are not MVP requirements.
|
||||
help actions during an active player match. The popover is constrained to
|
||||
safe areas, closes on outside activation or Escape, and never contains
|
||||
primary game, lobby, observer, or result actions.
|
||||
- The settings popover shows `Выйти из игры` only for an authenticated session.
|
||||
It opens a confirmation modal; an unfinished player match warns that it ends
|
||||
for everyone, while observer and lobby departures explain that only this
|
||||
browser's place is released.
|
||||
- A compact response indicator between the connection state and gear shows a
|
||||
rounded total response time or an explicit unknown, stale, or offline state.
|
||||
Its accessible description and settings details expose `Отклик`, `Связь`,
|
||||
and `ESP`; `Связь` is a transport remainder and does not assign blame to
|
||||
Wi-Fi, the ESP, or a player.
|
||||
- Countdown, pause, and result states appear as modal overlays above the map.
|
||||
- Textual system notices use one live message rail above the top bar and player
|
||||
status area. It shows one message at a time: connection loss has highest
|
||||
|
||||
@@ -103,6 +103,29 @@ test("latency correlation is bounded and only completes matching authoritative v
|
||||
assert.equal(tracker.pointerSnapshot().length, player.LATENCY_SAMPLE_LIMIT);
|
||||
});
|
||||
|
||||
test("connection quality uses one correlated heartbeat and bounded local smoothing", () => {
|
||||
let clock = 0;
|
||||
const quality = new player.ConnectionQuality(() => clock);
|
||||
assert.equal(quality.begin("heartbeat-1"), true);
|
||||
assert.equal(quality.begin("heartbeat-2"), false);
|
||||
clock = 85;
|
||||
assert.equal(quality.acknowledge("wrong", 2), false);
|
||||
assert.equal(quality.acknowledge("heartbeat-1", 2), true);
|
||||
assert.deepEqual(quality.latest, {totalMs: 85, communicationMs: 83, handlingMs: 2, received: 85});
|
||||
for (let index = 0; index < player.QUALITY_SAMPLE_LIMIT + 2; index++) {
|
||||
clock += 10;
|
||||
assert.equal(quality.begin(`heartbeat-${index + 2}`), true);
|
||||
clock += 20;
|
||||
assert.equal(quality.acknowledge(`heartbeat-${index + 2}`, 1), true);
|
||||
}
|
||||
assert.equal(quality.samples.length, player.QUALITY_SAMPLE_LIMIT);
|
||||
assert.equal(quality.acknowledge("heartbeat-99", 1001), false);
|
||||
clock += player.HEARTBEAT_STALE_MS + 1;
|
||||
assert.equal(quality.status(), "stale");
|
||||
quality.reset();
|
||||
assert.equal(quality.status(), "unknown");
|
||||
});
|
||||
|
||||
test("player map cells enlarge small projections without exceeding a readable bound", () => {
|
||||
const sevenBySeven = player.playerCellSize(396, 730, 7, 7);
|
||||
const fourByFour = player.playerCellSize(396, 730, 4, 4);
|
||||
@@ -338,8 +361,9 @@ test("refresh contract restores only the token and waits for server projection",
|
||||
assert.equal("position" in restore.payload, false);
|
||||
assert.equal("inventory" in restore.payload, false);
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
assert.equal((script.match(/localStorage\.setItem/g) || []).length, 1);
|
||||
assert.equal((script.match(/localStorage\.setItem/g) || []).length, 2);
|
||||
assert.match(script, /localStorage\.setItem\(TOKEN_KEY, message\.payload\.token\)/);
|
||||
assert.match(script, /localStorage\.setItem\(NAME_KEY, byId\("player-name"\)\.value\.slice\(0, 32\)\)/);
|
||||
});
|
||||
|
||||
test("player assets are local, Russian, accessible, and responsive", () => {
|
||||
@@ -392,6 +416,19 @@ test("one compact settings popover replaces standalone secondary controls", () =
|
||||
assert.match(script, /button\.textContent = reduced \? "Движение: выкл" : "Движение: вкл"/);
|
||||
});
|
||||
|
||||
test("connection quality stays compact and exposes an honest response split", () => {
|
||||
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
assert.match(html, /id="quality"[^>]*aria-label="Отклик неизвестен"/);
|
||||
assert.match(html, /id="quality-details"/);
|
||||
assert.match(css, /\.quality-pill \{[^}]*min-width: 48px/);
|
||||
assert.match(script, /send\("heartbeat", \{\}, null\)/);
|
||||
assert.match(script, /communicationMs: Math\.max\(0, smoothedMs - handlingMs\)/);
|
||||
assert.match(script, /message\.payload\.command === "heartbeat"/);
|
||||
assert.match(script, /documentObject\.hidden/);
|
||||
});
|
||||
|
||||
test("reference-derived SVG gameplay symbols preserve local yellow and blue ownership", () => {
|
||||
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8");
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
|
||||
@@ -13,15 +13,18 @@
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/" aria-label="maze32, на главную">MAZE<span>32</span></a>
|
||||
<div class="connection-pill" id="connection" data-state="connecting" role="status"><span class="signal" aria-hidden="true"></span><span id="connection-text">Ловим сигнал ESP…</span></div>
|
||||
<span class="quality-pill" id="quality" data-state="unknown" role="status" aria-live="polite" aria-label="Отклик неизвестен"><svg aria-hidden="true"><use href="/icons.svg#signal"></use></svg><span id="quality-text">—</span></span>
|
||||
<button class="icon-button" id="settings-toggle" type="button" aria-controls="settings-menu" aria-expanded="false" aria-label="Открыть настройки"><svg aria-hidden="true"><use href="/icons.svg#gear"></use></svg></button>
|
||||
</header>
|
||||
<aside class="settings-menu" id="settings-menu" hidden aria-label="Настройки">
|
||||
<strong>Настройки</strong>
|
||||
<span class="settings-details" id="details-text">Подключение ещё не готово.</span>
|
||||
<span class="settings-quality" id="quality-details">Отклик: неизвестен</span>
|
||||
<button class="settings-item" id="settings-mute" type="button" aria-pressed="false" hidden>Звук: вкл</button>
|
||||
<button class="settings-item" id="settings-motion" type="button" aria-pressed="false" hidden>Движение: вкл</button>
|
||||
<button class="settings-item" id="settings-help" type="button" hidden>Показать обучение</button>
|
||||
<a class="settings-item" id="settings-wifi" href="/provision">Настроить Wi‑Fi</a>
|
||||
<button class="settings-item settings-exit" id="settings-leave" type="button" hidden>Выйти из игры</button>
|
||||
</aside>
|
||||
|
||||
<section class="screen welcome-screen" id="welcome-screen" aria-labelledby="welcome-title">
|
||||
@@ -86,6 +89,7 @@
|
||||
</main>
|
||||
|
||||
<div class="modal" id="modal" hidden role="dialog" aria-modal="true" aria-labelledby="modal-title"><div class="modal-card"><svg id="modal-icon" aria-hidden="true"><use href="/icons.svg#hourglass"></use></svg><p class="eyebrow" id="modal-kicker">Серверное объявление</p><h2 id="modal-title">Приготовились!</h2><div class="countdown" id="countdown" hidden>3</div><p id="modal-text">Один сервер — один честный момент.</p><button class="button primary" id="modal-action" type="button" hidden>Вернуться в лобби</button></div></div>
|
||||
<section class="modal" id="leave-confirmation" hidden role="dialog" aria-modal="true" aria-labelledby="leave-title"><div class="modal-card"><p class="eyebrow">Подтверждение</p><h2 id="leave-title">Выйти из игры?</h2><p id="leave-text"></p><div class="button-row"><button class="button primary" id="leave-accept" type="button">Выйти</button><button class="button" id="leave-cancel" type="button">Остаться</button></div></div></section>
|
||||
<script src="/player.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -36,6 +36,11 @@ button { color: inherit; }
|
||||
.brand { color: var(--ink); text-decoration: none; font-weight: 1000; font-size: clamp(1.1rem, 3vw, 1.45rem); letter-spacing: .14em; text-shadow: 3px 3px 0 #000; }
|
||||
.brand span { color: var(--aqua); }
|
||||
.connection-pill { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.quality-pill { display: flex; align-items: center; gap: 4px; min-width: 48px; color: var(--muted); font-size: .68rem; font-weight: 800; white-space: nowrap; }
|
||||
.quality-pill svg { width: 17px; height: 17px; }
|
||||
.quality-pill[data-state="good"] { color: var(--success); }
|
||||
.quality-pill[data-state="delayed"] { color: var(--warning); }
|
||||
.quality-pill[data-state="slow"], .quality-pill[data-state="offline"] { color: var(--error); }
|
||||
.signal { width: 9px; height: 9px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 10px var(--warning); }
|
||||
.connection-pill[data-state="online"] .signal { background: var(--success); box-shadow: 0 0 10px var(--success); }
|
||||
.connection-pill[data-state="offline"] .signal { background: var(--disconnect); box-shadow: 0 0 10px var(--disconnect); }
|
||||
@@ -43,9 +48,11 @@ button { color: inherit; }
|
||||
.icon-button svg { width: 100%; height: 100%; color: var(--aqua); }
|
||||
.settings-menu { position: fixed; z-index: 40; top: max(58px, calc(env(safe-area-inset-top) + 58px)); right: max(12px, env(safe-area-inset-right)); display: grid; gap: 8px; width: min(300px, calc(100% - 24px)); max-height: calc(100dvh - max(70px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom))); overflow-y: auto; padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; }
|
||||
.settings-details { color: var(--muted); line-height: 1.45; }
|
||||
.settings-quality { color: var(--muted); line-height: 1.45; }
|
||||
.settings-item { min-height: 42px; display: flex; align-items: center; justify-content: space-between; border: 1px solid var(--edge); background: #0c0815; padding: 8px 10px; color: var(--ink); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
|
||||
.settings-item:hover, .settings-item:focus-visible { border-color: var(--aqua); background: #164049; }
|
||||
.settings-item[aria-pressed="true"] { border-color: var(--warning); color: var(--warning); }
|
||||
.settings-exit { border-color: var(--error); color: var(--error); }
|
||||
|
||||
.screen { min-height: calc(100dvh - 90px); animation: screen-in .28s steps(5, end); }
|
||||
.welcome-screen { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 500px); align-items: center; gap: clamp(24px, 6vw, 90px); padding: 5vh 4vw; }
|
||||
@@ -290,6 +297,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.dpad { right: max(12px, env(safe-area-inset-right)); bottom: max(12px, env(safe-area-inset-bottom)); grid-template-columns: repeat(3, 62px); grid-template-rows: repeat(3, 62px); gap: 5px; }
|
||||
.dpad button { min-width: 62px; min-height: 62px; font-size: 1.3rem; }
|
||||
.connection-pill { font-size: .58rem; }
|
||||
.quality-pill { min-width: 40px; font-size: .6rem; }
|
||||
.connection-pill #connection-text { max-width: 145px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
}
|
||||
|
||||
|
||||
+161
-5
@@ -2,6 +2,7 @@
|
||||
"use strict";
|
||||
|
||||
const TOKEN_KEY = "maze32.session.v1";
|
||||
const NAME_KEY = "maze32.player-name.v1";
|
||||
const MAX_VIEW_CHUNKS = 90;
|
||||
const MAX_VIEW_WIDTH = 32;
|
||||
const MAX_VIEW_HEIGHT = 32;
|
||||
@@ -16,6 +17,9 @@
|
||||
const AVATAR_FAST_REPEAT_MS = 170;
|
||||
const DIRECTIONAL_PAD_REPEAT_MS = 170;
|
||||
const LATENCY_SAMPLE_LIMIT = 256;
|
||||
const QUALITY_SAMPLE_LIMIT = 5;
|
||||
const HEARTBEAT_INTERVAL_MS = 5000;
|
||||
const HEARTBEAT_STALE_MS = 15000;
|
||||
const ONBOARDING_STEPS = Object.freeze([
|
||||
Object.freeze({icon: "hand", title: "Потяни героя", text: "Потяни героя — лабиринт поедет под ним."}),
|
||||
Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}),
|
||||
@@ -249,6 +253,50 @@
|
||||
pointerSnapshot() { return this.pointerSamples.slice(); }
|
||||
}
|
||||
|
||||
class ConnectionQuality {
|
||||
constructor(now) {
|
||||
this.now = typeof now === "function" ? now : () => root.performance.now();
|
||||
this.pending = null;
|
||||
this.samples = [];
|
||||
this.latest = null;
|
||||
this.level = "unknown";
|
||||
}
|
||||
|
||||
begin(requestId) {
|
||||
if (this.pending || !requestId) return false;
|
||||
this.pending = {requestId, sent: this.now()};
|
||||
return true;
|
||||
}
|
||||
|
||||
acknowledge(requestId, handlingMs) {
|
||||
if (!this.pending || this.now() - this.pending.sent > HEARTBEAT_STALE_MS || this.pending.requestId !== requestId || !Number.isInteger(handlingMs) || handlingMs < 0 || handlingMs > 1000) return false;
|
||||
const totalMs = Math.max(0, this.now() - this.pending.sent);
|
||||
this.pending = null;
|
||||
this.samples.push(totalMs);
|
||||
if (this.samples.length > QUALITY_SAMPLE_LIMIT) this.samples.shift();
|
||||
const ordered = this.samples.slice().sort((left, right) => left - right);
|
||||
const smoothedMs = ordered[Math.floor(ordered.length / 2)];
|
||||
this.latest = {totalMs: smoothedMs, communicationMs: Math.max(0, smoothedMs - handlingMs), handlingMs, received: this.now()};
|
||||
if (this.level === "good") this.level = smoothedMs > 175 ? "delayed" : "good";
|
||||
else if (this.level === "slow") this.level = smoothedMs <= 450 ? "delayed" : "slow";
|
||||
else if (smoothedMs <= 150) this.level = "good";
|
||||
else this.level = smoothedMs <= 500 ? "delayed" : "slow";
|
||||
return true;
|
||||
}
|
||||
|
||||
reset() { this.pending = null; this.samples = []; this.latest = null; this.level = "unknown"; }
|
||||
|
||||
expirePending() {
|
||||
if (this.pending && this.now() - this.pending.sent > HEARTBEAT_STALE_MS) this.pending = null;
|
||||
}
|
||||
|
||||
status() {
|
||||
if (!this.latest) return "unknown";
|
||||
if (this.now() - this.latest.received > HEARTBEAT_STALE_MS) return "stale";
|
||||
return this.level;
|
||||
}
|
||||
}
|
||||
|
||||
function createEnvelope(type, sequence, requestId, payload) {
|
||||
return {v: 1, type, seq: sequence, requestId, payload: payload || {}};
|
||||
}
|
||||
@@ -355,7 +403,7 @@
|
||||
|
||||
function boot(documentObject, windowObject) {
|
||||
const byId = id => documentObject.getElementById(id);
|
||||
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null, breakthroughTimer: null, travelTimer: null}, hedge: {openedKeys: new Set()}, moveDirections: new Map(), touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, startX: null, startY: null, direction: null, repeatTimer: null, repeatMs: null}, dpad: {pointerId: null, direction: null, repeatTimer: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
|
||||
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, heartbeatTimer: null, leaveRequest: null, noticeTimer: null, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null, breakthroughTimer: null, travelTimer: null}, hedge: {openedKeys: new Set()}, moveDirections: new Map(), touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), quality: new ConnectionQuality(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, startX: null, startY: null, direction: null, repeatTimer: null, repeatMs: null}, dpad: {pointerId: null, direction: null, repeatTimer: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
|
||||
state.moveFlow = new LatestIntentFlow((direction, element) => sendMoveNow(direction, element));
|
||||
|
||||
function showNotice(text, kind, priority, persistent, key) {
|
||||
@@ -423,6 +471,7 @@
|
||||
byId("observer-screen").hidden = name !== "observer";
|
||||
documentObject.body.classList.toggle("match-active", name === "match");
|
||||
["settings-mute", "settings-motion", "settings-help"].forEach(id => { byId(id).hidden = name !== "match"; });
|
||||
byId("settings-leave").hidden = state.session == null;
|
||||
if (name !== "match") stopBreakthroughTravel();
|
||||
if (name !== "match") closeSettings(false);
|
||||
}
|
||||
@@ -444,6 +493,40 @@
|
||||
if (firstAction) firstAction.focus();
|
||||
}
|
||||
|
||||
function openLeaveConfirmation() {
|
||||
closeSettings(false);
|
||||
const phase = state.snapshot ? state.snapshot.phase : state.lobby && state.lobby.phase;
|
||||
const endsMatch = state.session && state.session.role === "player" &&
|
||||
["countdown", "active", "finish_pending", "paused"].includes(phase);
|
||||
byId("leave-text").textContent = endsMatch
|
||||
? "Текущая гонка закончится для всех, а ваш слот сразу освободится."
|
||||
: "Освободим только ваше место в игре.";
|
||||
byId("leave-confirmation").hidden = false;
|
||||
byId("leave-accept").focus();
|
||||
}
|
||||
|
||||
function closeLeaveConfirmation() {
|
||||
byId("leave-confirmation").hidden = true;
|
||||
byId("settings-toggle").focus();
|
||||
}
|
||||
|
||||
function finishLeave() {
|
||||
windowObject.localStorage.removeItem(TOKEN_KEY);
|
||||
state.session = null;
|
||||
state.lobby = null;
|
||||
state.snapshot = null;
|
||||
state.leaveRequest = null;
|
||||
state.pending.clear();
|
||||
resetPlayerControls();
|
||||
clearEffects();
|
||||
stopHeartbeat();
|
||||
byId("maze").replaceChildren();
|
||||
byId("map-placeholder").hidden = false;
|
||||
byId("player-name").value = windowObject.localStorage.getItem(NAME_KEY) || "";
|
||||
closeLeaveConfirmation();
|
||||
setScreen("welcome");
|
||||
}
|
||||
|
||||
function renderWelcomeHeadline(headline) {
|
||||
const title = byId("welcome-title");
|
||||
const highlight = documentObject.createElement("span");
|
||||
@@ -457,9 +540,51 @@
|
||||
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
|
||||
if (connectionState === "offline") { resetPlayerControls(); state.moveDirections.clear(); showNotice("Связь ушла гулять. Ждём её здесь.", "error", 3, true, "offline"); }
|
||||
else if (state.rail.persistent) showNotice("Связь вернулась! Кусты снова в игре.", "success", 3, false, "online");
|
||||
renderQuality();
|
||||
}
|
||||
|
||||
function renderQuality() {
|
||||
const quality = state.quality.latest;
|
||||
const status = !state.socket || state.socket.readyState !== windowObject.WebSocket.OPEN ? "offline" : state.quality.status();
|
||||
const indicator = byId("quality");
|
||||
indicator.dataset.state = status;
|
||||
if (!quality || status === "unknown" || status === "stale" || status === "offline") {
|
||||
const label = status === "offline" ? "нет связи" : status === "stale" ? "устарело" : "неизвестно";
|
||||
byId("quality-text").textContent = status === "offline" ? "нет" : "—";
|
||||
indicator.setAttribute("aria-label", `Отклик: ${label}`);
|
||||
byId("quality-details").textContent = `Отклик: ${label}`;
|
||||
return;
|
||||
}
|
||||
const total = Math.round(quality.totalMs);
|
||||
const communication = Math.round(quality.communicationMs);
|
||||
const handling = Math.round(quality.handlingMs);
|
||||
byId("quality-text").textContent = `${total} мс`;
|
||||
indicator.setAttribute("aria-label", `Отклик ${total} мс. Связь ${communication} мс. ESP ${handling} мс.`);
|
||||
byId("quality-details").textContent = `Отклик: ${total} мс · Связь: ${communication} мс · ESP: ${handling} мс. Связь — остаток транспорта, не только Wi‑Fi.`;
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
windowObject.clearTimeout(state.heartbeatTimer);
|
||||
state.heartbeatTimer = null;
|
||||
state.quality.reset();
|
||||
renderQuality();
|
||||
}
|
||||
|
||||
function scheduleHeartbeat(delay) {
|
||||
windowObject.clearTimeout(state.heartbeatTimer);
|
||||
state.heartbeatTimer = windowObject.setTimeout(() => {
|
||||
state.quality.expirePending();
|
||||
if (documentObject.hidden || !state.session || state.quality.pending || !state.socket || state.socket.readyState !== windowObject.WebSocket.OPEN) {
|
||||
scheduleHeartbeat(HEARTBEAT_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
send("heartbeat", {}, null);
|
||||
scheduleHeartbeat(HEARTBEAT_INTERVAL_MS);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
renderWelcomeHeadline(randomLoginHeadline());
|
||||
byId("player-name").value = windowObject.localStorage.getItem(NAME_KEY) || "";
|
||||
|
||||
function beginFeedback() {
|
||||
if (state.feedbackReady) return;
|
||||
@@ -505,6 +630,7 @@
|
||||
}
|
||||
const sequence = ++state.clientSequence;
|
||||
const requestId = `${type}-${sequence}`;
|
||||
if (type === "heartbeat" && !state.quality.begin(requestId)) return null;
|
||||
state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload)));
|
||||
setPending(requestId, element);
|
||||
if (type === "move") { state.latency.sent(requestId); state.moveDirections.set(requestId, payload.direction); }
|
||||
@@ -852,7 +978,9 @@
|
||||
clearPending(message.requestId);
|
||||
if (message.type === "session") {
|
||||
state.session = message.payload; windowObject.localStorage.setItem(TOKEN_KEY, message.payload.token);
|
||||
if (message.payload.role === "player") windowObject.localStorage.setItem(NAME_KEY, byId("player-name").value.slice(0, 32));
|
||||
showNotice(message.payload.role === "player" ? `Слот P${message.payload.slot} закреплён за этим браузером.` : "Вы в зрительном зале. Руками не трогаем!", "success");
|
||||
scheduleHeartbeat(0);
|
||||
if (state.lobby) renderLobby();
|
||||
} else if (message.type === "lobby") { state.lobby = message.payload; renderLobby();
|
||||
} else if (message.type === "countdown") { showCountdown(message.payload);
|
||||
@@ -861,7 +989,16 @@
|
||||
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
|
||||
} else if (message.type === "match_result") { renderResult(message.payload.result);
|
||||
} else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); resetPlayerControls(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
|
||||
} else if (message.type === "ack") { state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); state.moveDirections.delete(message.requestId); state.moveFlow.complete(message.requestId); feedback(true);
|
||||
} else if (message.type === "ack") {
|
||||
if (message.payload && message.payload.command === "leave" && message.requestId === state.leaveRequest) {
|
||||
finishLeave();
|
||||
return;
|
||||
}
|
||||
if (message.payload && message.payload.command === "heartbeat") {
|
||||
if (state.quality.acknowledge(message.requestId, message.payload.handlingMs)) renderQuality();
|
||||
return;
|
||||
}
|
||||
state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); state.moveDirections.delete(message.requestId); state.moveFlow.complete(message.requestId); feedback(true);
|
||||
} else if (message.type === "error") {
|
||||
const direction = state.moveDirections.get(message.requestId);
|
||||
state.moveDirections.delete(message.requestId);
|
||||
@@ -879,7 +1016,7 @@
|
||||
state.socket = new windowObject.WebSocket(`${scheme}://${windowObject.location.host}/ws`);
|
||||
state.socket.onopen = () => { setConnection("online", "ESP на связи"); const token = windowObject.localStorage.getItem(TOKEN_KEY); if (token) send("restore", {token}, null); };
|
||||
state.socket.onmessage = event => { let message; try { message = JSON.parse(event.data); } catch (_) { showNotice("Лабиринт прислал каракули. Ждём понятный знак.", "error"); return; } handleMessage(message); };
|
||||
state.socket.onclose = () => { state.moveFlow.disconnect(); setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
|
||||
state.socket.onclose = () => { state.moveFlow.disconnect(); stopHeartbeat(); setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
|
||||
state.socket.onerror = () => showNotice("Связь чихнула. Пробуем снова…", "error");
|
||||
}
|
||||
|
||||
@@ -986,6 +1123,11 @@
|
||||
|
||||
documentObject.addEventListener("pointerdown", beginFeedback, {once: true});
|
||||
documentObject.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape" && !byId("leave-confirmation").hidden) {
|
||||
event.preventDefault();
|
||||
closeLeaveConfirmation();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && !byId("settings-menu").hidden) {
|
||||
event.preventDefault();
|
||||
closeSettings(true);
|
||||
@@ -1022,7 +1164,14 @@
|
||||
};
|
||||
windowObject.addEventListener("resize", refreshPlayerFocalPoint);
|
||||
windowObject.addEventListener("orientationchange", refreshPlayerFocalPoint);
|
||||
documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetPlayerControls(); });
|
||||
documentObject.addEventListener("visibilitychange", () => {
|
||||
if (documentObject.hidden) {
|
||||
resetPlayerControls();
|
||||
stopHeartbeat();
|
||||
} else if (state.session && state.socket && state.socket.readyState === windowObject.WebSocket.OPEN) {
|
||||
scheduleHeartbeat(0);
|
||||
}
|
||||
});
|
||||
documentObject.querySelectorAll("[data-direction]").forEach(button => {
|
||||
button.addEventListener("pointerdown", event => {
|
||||
if (button.disabled) return;
|
||||
@@ -1056,6 +1205,13 @@
|
||||
byId("settings-motion").addEventListener("click", () => setReducedMotion(!state.reducedMotion));
|
||||
byId("settings-help").addEventListener("click", () => { closeSettings(false); showOnboarding(0); });
|
||||
byId("settings-wifi").addEventListener("click", () => closeSettings(false));
|
||||
byId("settings-leave").addEventListener("click", openLeaveConfirmation);
|
||||
byId("leave-cancel").addEventListener("click", closeLeaveConfirmation);
|
||||
byId("leave-accept").addEventListener("click", () => {
|
||||
const requestId = send("leave", {}, byId("leave-accept"));
|
||||
if (requestId) state.leaveRequest = requestId;
|
||||
else showNotice("Не удалось отправить выход. Связь продолжается.", "error");
|
||||
});
|
||||
byId("onboarding-next").addEventListener("click", () => {
|
||||
const next = nextOnboardingStep(state.onboardingStep);
|
||||
if (next == null) hideOnboarding();
|
||||
@@ -1096,7 +1252,7 @@
|
||||
return {state, handleMessage, move, resetAvatarControl, latencySamples: () => state.latency.snapshot(), pointerFeedbackSamples: () => state.latency.pointerSnapshot()};
|
||||
}
|
||||
|
||||
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_ADJECTIVES, NAME_NOUNS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, DIRECTIONAL_PAD_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, playerFocalPoint, playerFocalMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, hedgeEdgeKey, hedgeKeyForMove, hedgeVariant, projectedHedgeEdges, clampObserverZoom, observerFitScale, boot});
|
||||
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_ADJECTIVES, NAME_NOUNS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, QUALITY_SAMPLE_LIMIT, HEARTBEAT_INTERVAL_MS, HEARTBEAT_STALE_MS, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, DIRECTIONAL_PAD_REPEAT_MS, LatestIntentFlow, LatencyTracker, ConnectionQuality, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, playerFocalPoint, playerFocalMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, hedgeEdgeKey, hedgeKeyForMove, hedgeVariant, projectedHedgeEdges, clampObserverZoom, observerFitScale, boot});
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
root.Maze32Player = api;
|
||||
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });
|
||||
|
||||
Reference in New Issue
Block a user