Compare commits

...

6 Commits

24 changed files with 1819 additions and 69 deletions
+22 -1
View File
@@ -195,6 +195,16 @@ stateDiagram-v2
During `Paused`, movement commands are rejected and move-counted effects do not During `Paused`, movement commands are rejected and move-counted effects do not
advance. During `FinishPending`, the first exit is retained while the server advance. During `FinishPending`, the first exit is retained while the server
waits long enough to determine whether the second exit creates a draw. waits long enough to determine whether the second exit creates a draw.
An authenticated explicit player leave from any non-waiting phase, including
`Finished`, stops the current lifecycle and returns remaining sessions to the
waiting lobby. This is distinct from an unexpected disconnect, which preserves
the player's reconnect reservation for 60 seconds; observer leave and player
leave from `Lobby` revoke only the departing session.
The game stores a fixed two-bit owner mask for each canonical traversable
edge. View construction exposes those masks only for authorized visible edges,
and the browser replaces its bounded trail drawing layer from each complete
projection.
## 6. Authoritative command processing ## 6. Authoritative command processing
@@ -345,7 +355,18 @@ already visible bush wall; it never derives or opens topology locally.
Browser hedge rendering derives a bounded canonical edge list solely from the Browser hedge rendering derives a bounded canonical edge list solely from the
current role projection. Its deterministic local SVG selection is presentation current role projection. Its deterministic local SVG selection is presentation
only: it cannot create maze edges, alter hit testing, retain hidden cells, or only: it cannot create maze edges, alter hit testing, retain hidden cells, or
change authoritative movement and visibility. change authoritative movement and visibility. A player-only breakthrough
animation may begin only when a newer projection contains both a newly opened
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 Player projections are translated only within their received cells so the
authoritative `self` cell aligns with the responsive local focal point. That authoritative `self` cell aligns with the responsive local focal point. That
+12
View File
@@ -216,6 +216,18 @@ Browser controls must support:
state. state.
- If the player does not reconnect within 60 seconds, the match ends in a draw. - If the player does not reconnect within 60 seconds, the match ends in a draw.
- Reloading a page is treated as a reconnect, not as leaving the match. - Reloading a page is treated as a reconnect, not as leaving the match.
- A confirmed explicit player exit during any non-waiting phase, including a
finished result, revokes only that session and authoritatively returns all
remaining participants to a fresh waiting lobby. Observer exits and player
exits from an already waiting lobby affect only the departing session.
### Player trails
- Every successful in-maze player move is recorded authoritatively as a
persistent passage trail for the current match. Players receive only trail
segments inside their current fog-of-war projection; observers receive both
complete trails. Trails clear with the match and never come from browser
history.
## 11. Browser presentation ## 11. Browser presentation
+1003 -6
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -22,9 +22,11 @@ extern "C" {
#define MAZE32_GAME_MAX_OBJECTS \ #define MAZE32_GAME_MAX_OBJECTS \
(6U + 2U * MAZE32_MAZE_MAX_LANDMARK_PAIRS + 2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS) (6U + 2U * MAZE32_MAZE_MAX_LANDMARK_PAIRS + 2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS)
#define MAZE32_GAME_VIEW_CELLS_PER_CHUNK 12U #define MAZE32_GAME_VIEW_CELLS_PER_CHUNK 12U
#define MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS 8U #define MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS 5U
#define MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK 6U #define MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK 6U
#define MAZE32_GAME_VIEW_CHUNK_BYTES 512U #define MAZE32_GAME_VIEW_CHUNK_BYTES 512U
#define MAZE32_GAME_MAX_TRAIL_EDGES (2U * MAZE32_MAZE_MAX_CELLS)
#define MAZE32_GAME_TRAIL_BYTES ((MAZE32_GAME_MAX_TRAIL_EDGES + 3U) / 4U)
#define MAZE32_GAME_RAM_BUDGET_BYTES (40U * 1024U) #define MAZE32_GAME_RAM_BUDGET_BYTES (40U * 1024U)
#define MAZE32_GAME_RUNTIME_BUDGET_MS 50U #define MAZE32_GAME_RUNTIME_BUDGET_MS 50U
@@ -163,6 +165,7 @@ typedef struct {
maze32_game_exit_t exits[MAZE32_GAME_PLAYER_COUNT]; maze32_game_exit_t exits[MAZE32_GAME_PLAYER_COUNT];
uint8_t consumed_artifacts[MAZE32_GAME_MAX_ARTIFACTS]; uint8_t consumed_artifacts[MAZE32_GAME_MAX_ARTIFACTS];
uint8_t opened_passages[MAZE32_MAZE_MAX_CELLS]; uint8_t opened_passages[MAZE32_MAZE_MAX_CELLS];
uint8_t trail_owners[MAZE32_GAME_TRAIL_BYTES];
maze32_game_command_t commands[MAZE32_GAME_COMMAND_CAPACITY]; maze32_game_command_t commands[MAZE32_GAME_COMMAND_CAPACITY];
uint8_t command_head; uint8_t command_head;
uint8_t command_count; uint8_t command_count;
@@ -189,6 +192,8 @@ typedef struct {
uint16_t cell_index; uint16_t cell_index;
uint8_t passages; uint8_t passages;
uint8_t opened_passages; uint8_t opened_passages;
uint8_t trail_east;
uint8_t trail_south;
} maze32_view_cell_t; } maze32_view_cell_t;
typedef struct { typedef struct {
+48 -2
View File
@@ -68,6 +68,36 @@ static int game_has_passage(const maze32_game_t* game, maze32_cell_t cell,
return (game_passages(game, cell) & passage) != 0U; return (game_passages(game, cell) & passage) != 0U;
} }
static uint16_t trail_edge_index(const maze32_maze_t* maze, maze32_cell_t cell,
maze32_passage_t passage) {
uint16_t horizontal_count = (uint16_t)(maze->config.height * (maze->config.width - 1U));
if (passage == MAZE32_PASSAGE_EAST) {
return (uint16_t)(cell.y * (maze->config.width - 1U) + cell.x);
}
return (uint16_t)(horizontal_count + cell.y * maze->config.width + cell.x);
}
static uint8_t trail_owner_mask(const maze32_game_t* game, uint16_t edge_index) {
uint8_t byte = game->trail_owners[edge_index / 4U];
return (uint8_t)((byte >> ((edge_index % 4U) * 2U)) & 0x03U);
}
static void mark_trail_edge(maze32_game_t* game, uint8_t player_index, maze32_cell_t origin,
maze32_game_direction_t direction) {
maze32_cell_t canonical = origin;
maze32_passage_t passage = direction_passage(direction);
if (direction == MAZE32_GAME_DIRECTION_NORTH) {
canonical.y--;
passage = MAZE32_PASSAGE_SOUTH;
} else if (direction == MAZE32_GAME_DIRECTION_WEST) {
canonical.x--;
passage = MAZE32_PASSAGE_EAST;
}
uint16_t edge_index = trail_edge_index(game->maze, canonical, passage);
uint8_t shift = (uint8_t)((edge_index % 4U) * 2U);
game->trail_owners[edge_index / 4U] |= (uint8_t)((1U << player_index) << shift);
}
static void reset_perseverance(maze32_game_player_t* player) { static void reset_perseverance(maze32_game_player_t* player) {
player->perseverance_hits = 0U; player->perseverance_hits = 0U;
player->perseverance_last_impact_ms = 0U; player->perseverance_last_impact_ms = 0U;
@@ -518,6 +548,7 @@ maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) {
} }
finish_outward_move(game, player_index, command.submitted_ms, &processed); finish_outward_move(game, player_index, command.submitted_ms, &processed);
} else { } else {
maze32_cell_t origin = player->position;
maze32_passage_t passage = direction_passage(command.direction); maze32_passage_t passage = direction_passage(command.direction);
if (!game_has_passage(game, player->position, passage)) { if (!game_has_passage(game, player->position, passage)) {
processed.breakthrough_hits = record_wall_impact( processed.breakthrough_hits = record_wall_impact(
@@ -544,6 +575,7 @@ maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) {
return processed; return processed;
} }
player->position = moved_cell(player->position, command.direction); player->position = moved_cell(player->position, command.direction);
mark_trail_edge(game, player_index, origin, command.direction);
accept_player_move(player, command.submitted_ms); accept_player_move(player, command.submitted_ms);
processed.moved = 1U; processed.moved = 1U;
resolve_cell_entry(game, player_index, &processed); resolve_cell_entry(game, player_index, &processed);
@@ -670,6 +702,20 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_
view->cells[view->cell_count].cell_index = index; view->cells[view->cell_count].cell_index = index;
view->cells[view->cell_count].passages = game_passages(game, cell); view->cells[view->cell_count].passages = game_passages(game, cell);
view->cells[view->cell_count].opened_passages = game->opened_passages[index]; view->cells[view->cell_count].opened_passages = game->opened_passages[index];
if (x + 1U < game->maze->config.width) {
maze32_cell_t east = {x + 1U, y};
if (reveal_all || is_visible(center, east, radius)) {
view->cells[view->cell_count].trail_east = trail_owner_mask(
game, trail_edge_index(game->maze, cell, MAZE32_PASSAGE_EAST));
}
}
if (y + 1U < game->maze->config.height) {
maze32_cell_t south = {x, y + 1U};
if (reveal_all || is_visible(center, south, radius)) {
view->cells[view->cell_count].trail_south = trail_owner_mask(
game, trail_edge_index(game->maze, cell, MAZE32_PASSAGE_SOUTH));
}
}
view->cell_count++; view->cell_count++;
} }
} }
@@ -861,9 +907,9 @@ maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view,
} }
for (size_t index = first; index < end; ++index) { for (size_t index = first; index < end; ++index) {
const maze32_view_cell_t* cell = &view->cells[index]; const maze32_view_cell_t* cell = &view->cells[index];
if (!append_text(output, output_size, &offset, "%s[%u,%u,%u]", if (!append_text(output, output_size, &offset, "%s[%u,%u,%u,%u,%u]",
index == first ? "" : ",", cell->cell_index, cell->passages, index == first ? "" : ",", cell->cell_index, cell->passages,
cell->opened_passages)) { cell->opened_passages, cell->trail_east, cell->trail_south)) {
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL; return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
} }
} }
@@ -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, maze32_lobby_result_t maze32_lobby_restore(maze32_lobby_t* lobby, int connection_id,
const char* token, maze32_session_t** session); const char* token, maze32_session_t** session);
void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id); 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); 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); int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms);
void maze32_lobby_reset_match(maze32_lobby_t* lobby); void maze32_lobby_reset_match(maze32_lobby_t* lobby);
@@ -22,6 +22,7 @@ typedef enum {
MAZE32_MESSAGE_MOVE, MAZE32_MESSAGE_MOVE,
MAZE32_MESSAGE_DROP_KEY, MAZE32_MESSAGE_DROP_KEY,
MAZE32_MESSAGE_RESTART, MAZE32_MESSAGE_RESTART,
MAZE32_MESSAGE_LEAVE,
MAZE32_MESSAGE_HEARTBEAT, MAZE32_MESSAGE_HEARTBEAT,
} maze32_message_type_t; } maze32_message_type_t;
+24
View File
@@ -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, maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id,
uint64_t now_ms) { uint64_t now_ms) {
if (lobby == NULL || lobby->phase != MAZE32_LOBBY_WAITING) { 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) { if (strcmp(value, "restart") == 0) {
return MAZE32_MESSAGE_RESTART; return MAZE32_MESSAGE_RESTART;
} }
if (strcmp(value, "leave") == 0) {
return MAZE32_MESSAGE_LEAVE;
}
if (strcmp(value, "heartbeat") == 0) { if (strcmp(value, "heartbeat") == 0) {
return MAZE32_MESSAGE_HEARTBEAT; return MAZE32_MESSAGE_HEARTBEAT;
} }
+47 -3
View File
@@ -948,6 +948,39 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
"Сначала выберите роль или восстановите сессию"); "Сначала выберите роль или восстановите сессию");
return; 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;
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 (message->type == MAZE32_MESSAGE_START) {
if (session->role == MAZE32_ROLE_OBSERVER) { if (session->role == MAZE32_ROLE_OBSERVER) {
ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=start", socket_fd); ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=start", socket_fd);
@@ -977,18 +1010,29 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
return; return;
} }
if (message->type == MAZE32_MESSAGE_HEARTBEAT) { if (message->type == MAZE32_MESSAGE_HEARTBEAT) {
int64_t heartbeat_started_us = esp_timer_get_time();
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
size_t length = 0U; size_t length = 0U;
char prefix[128]; char prefix[128];
char handling_text[32];
int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32, int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32,
next_server_sequence(gateway)); 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, prefix) &&
append_text(response, sizeof(response), &length, ",\"requestId\":") && append_text(response, sizeof(response), &length, ",\"requestId\":") &&
append_json_string(response, sizeof(response), &length, message->request_id) && append_json_string(response, sizeof(response), &length, message->request_id) &&
append_text(response, sizeof(response), &length, append_text(response, sizeof(response), &length,
",\"payload\":{\"command\":\"heartbeat\"}}")) { ",\"payload\":{\"command\":\"heartbeat\",\"handlingMs\":") &&
enqueue(gateway, socket_fd, response); 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.
+5
View File
@@ -14,6 +14,7 @@ browser timestamp from an ESP timestamp.
| command to painted view | browser | `move` send to next frame after matching view render | | command to painted view | browser | `move` send to next frame after matching view render |
| handler and send | ESP | existing `/diagnostics` maxima | | handler and send | ESP | existing `/diagnostics` maxima |
| network round trip | client | ICMP and WebSocket heartbeat samples | | 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 accepted command acknowledgement contains the authoritative `stateSequence`.
The browser records a sample only after the complete view with that sequence is 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 and send maxima, queue high-water marks, observer view chunk count, and
ICMP/WebSocket round trips. Attribute a delay only when the matching ICMP/WebSocket round trips. Attribute a delay only when the matching
interval is elevated. 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 ## Safety checks
+33 -3
View File
@@ -56,18 +56,48 @@ a maze boundary, and is recalculated on resize and rotation.
## Feedback and message rail ## Feedback and message rail
## Compact settings
The persistent gear button beside the connection indicator opens one compact
popover. It shows live connection details and Wi-Fi settings on every screen.
During an active player match it additionally provides sound, reduced-motion,
and optional-help controls with visible Russian on/off labels. Opening or
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.
Authenticated sessions also see `Выйти из игры`. Its confirmation states that
a player exit from any non-waiting phase, including the result screen, returns
the remaining participants to the waiting lobby; observer exits and waiting
lobby player exits release only the departing browser's place. The browser
removes its token only after the correlated server acknowledgement.
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 The browser stores the direction for each outstanding move request. A matching
server `blocked` rejection may include only that player's approved breakthrough 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 stage, which bends the already rendered bush wall for at most 340 ms without
blocked-move text. Opened-passage bits in an ordinary cell projection render as blocked-move text. A received opened-passage projection paired with an adjacent
a permanent trampled bush gap. Effects have one restartable timer and clear on authoritative player move may show one 480 ms hedge-parting dash and four
pause, result, reset, disconnect, and control reset. Reduced motion is static. bounded decorative accents before settling at the server-approved destination.
Opened-passage bits render a permanent trampled bush gap with a 60% clear
centre. Effects have restartable timers and clear on a newer projection, pause,
result, reset, disconnect, screen change, and control reset. Reduced motion is
static and settles directly at the received destination.
The map draws only a bounded canonical hedge-edge layer generated from current The map draws only a bounded canonical hedge-edge layer generated from current
received cells. It hashes each visible edge deterministically to choose the received cells. It hashes each visible edge deterministically to choose the
approved local SVG variant, so the browser neither retains unseen geometry nor approved local SVG variant, so the browser neither retains unseen geometry nor
decides topology. The layer is decorative and cannot receive input. decides topology. The layer is decorative and cannot receive input.
Each complete projection also replaces one bounded SVG trail layer. It draws
only server-authorized visible east/south passage masks: yellow and blue use
different dotted rhythms, and a shared passage retains both patterns.
All remaining Russian system messages use one `role=status` live message rail All remaining Russian system messages use one `role=status` live message rail
above the application top bar. One connection-loss message has highest priority above the application top bar. One connection-loss message has highest priority
and remains until a successful recovery; other messages deduplicate, replace and remains until a successful recovery; other messages deduplicate, replace
+11 -2
View File
@@ -39,6 +39,10 @@ is only echoed for correlation; it grants no authority.
- `restart`: `{}`. Only connected Player 1 may reset a finished match, and both - `restart`: `{}`. Only connected Player 1 may reset a finished match, and both
players must be connected. Sessions are preserved while a new authoritative players must be connected. Sessions are preserved while a new authoritative
maze and game state are created. maze and game state are created.
- `leave`: `{}`. Revokes only the authenticated sending session. A player
leaving any non-waiting match phase, including a finished result, cancels
that match and returns remaining sessions to the waiting lobby; observer and
waiting-lobby departures release only that session.
- `heartbeat`: `{}` after a session has been joined or restored. - `heartbeat`: `{}` after a session has been joined or restored.
## Server messages ## Server messages
@@ -52,7 +56,9 @@ is only echoed for correlation; it grants no authority.
- `active`: the exact authoritative activation instant. - `active`: the exact authoritative activation instant.
- `view`: a bounded chunk of a role-specific authoritative game projection. - `view`: a bounded chunk of a role-specific authoritative game projection.
Observer chunks contain the complete maze and both player records, including Observer chunks contain the complete maze and both player records, including
their connection state; player chunks retain the fog-of-war boundary. their connection state; player chunks retain the fog-of-war boundary. Each
projected cell carries compact Player 1 and Player 2 trail ownership masks
for its east and south edges only when both edge endpoints are authorized.
- `pause`: identifies the disconnected player slot and its monotonic reconnect - `pause`: identifies the disconnected player slot and its monotonic reconnect
deadline. deadline.
- `resume`: reports that both players are attached and authoritative gameplay - `resume`: reports that both players are attached and authoritative gameplay
@@ -60,7 +66,10 @@ is only echoed for correlation; it grants no authority.
- `match_result`: reports the terminal winner or draw. - `match_result`: reports the terminal winner or draw.
- `reset`: reports the authoritative return from a finished match to the - `reset`: reports the authoritative return from a finished match to the
waiting lobby. 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`. - `error`: a stable English `code` and Russian player-facing `message`.
An observer session can receive snapshots but cannot issue `start`, `move`, or An observer session can receive snapshots but cannot issue `start`, `move`, or
+32 -7
View File
@@ -100,8 +100,24 @@ are not MVP requirements.
- The player identity is represented by a distinct character shape, not `P1` - The player identity is represented by a distinct character shape, not `P1`
or `P2`. Object, blocked-move, pause, and result feedback must pair a shape or `P2`. Object, blocked-move, pause, and result feedback must pair a shape
or icon with its color. or icon with its color.
- Connection details and the Wi-Fi settings link belong in a secondary panel, - A single gear button beside the persistent Wi-Fi connection indicator opens
not in the persistent status row. the secondary settings popover. It contains connection details and Wi-Fi
settings on every screen, plus the player-only sound, reduced-motion, and
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; a player match, including a finished result,
warns that it returns everyone to the waiting lobby, while observer and
waiting-lobby departures explain that only this browser's place is released.
- Trails are dotted passage-center lines beneath maze objects and hedges: Player
1 uses the established yellow dot rhythm, Player 2 uses a distinct blue
rhythm, and shared passages draw both rhythms on one centerline.
- 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. - 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 - 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 status area. It shows one message at a time: connection loss has highest
@@ -130,9 +146,13 @@ are not MVP requirements.
- Server-approved breakthrough impacts one through four bend the visible bush - Server-approved breakthrough impacts one through four bend the visible bush
wall with four increasing restrained stages and no text or spike notice. A wall with four increasing restrained stages and no text or spike notice. A
fifth accepted move is reflected by a permanent textured trampled-bush gap. fifth accepted move is reflected by a permanent textured trampled-bush gap.
The browser uses one restartable impact timer and no generated particles. Only a received opened-edge projection paired with an adjacent authoritative
player position may start one 480 ms hedge-parting, player dash, and fixed
four-accent leaf-and-sparkle sequence. It uses one restartable timer and no
unbounded generated particles.
`rate_limited` still darkens and blurs only the maze for at most 520 ms. `rate_limited` still darkens and blurs only the maze for at most 520 ms.
- Reduced-motion mode uses static deformation and the same trampled-gap state. - Reduced-motion mode uses static deformation and the same trampled-gap state;
it never shows travel or moving accents.
### Hedge art review contract ### Hedge art review contract
@@ -152,8 +172,9 @@ are not MVP requirements.
- Impact stages one through four retain the same wall centre while increasingly - Impact stages one through four retain the same wall centre while increasingly
compressing foliage, bending twigs, and shedding a fixed small number of compressing foliage, bending twigs, and shedding a fixed small number of
leaves. The breakthrough and trampled symbols leave a clear centred passage leaves. The breakthrough and trampled symbols leave a clear centred passage
at least 16 CSS pixels wide at 40-pixel scale, with flattened vegetation on at least 24 CSS pixels wide at 40-pixel scale, with two separated foliage
both sides. They never imply movement beyond that one authoritative edge. clusters, bent branches, and restrained ground debris on both sides. They
never imply movement beyond that one authoritative edge.
- Review sheets show the source symbols on neutral and dark-cell backgrounds - Review sheets show the source symbols on neutral and dark-cell backgrounds
at 34, 40, and 46 CSS pixels. They contain no coordinates, objects, at 34, 40, and 46 CSS pixels. They contain no coordinates, objects,
ownership, hidden topology, animation, or runtime dependencies. ownership, hidden topology, animation, or runtime dependencies.
@@ -185,7 +206,11 @@ are not MVP requirements.
objects and players, and may overhang an edge by at most 15% of a cell. A objects and players, and may overhang an edge by at most 15% of a cell. A
projection replacement removes all obsolete edge nodes. One restartable projection replacement removes all obsolete edge nodes. One restartable
breakthrough timer may briefly show the approved breakthrough frame; reduced breakthrough timer may briefly show the approved breakthrough frame; reduced
motion settles immediately on the permanent breach. motion settles immediately on the permanent breach. A player-only transient
runner replaces the focal avatar for at most 480 ms after a matching opened
edge and adjacent authoritative move; it has exactly four non-interactive
accents and is cleared on any newer projection, pause, disconnect, result,
reset, screen change, or reduced-motion mode.
## 8. Responsive behavior ## 8. Responsive behavior
+36
View File
@@ -324,12 +324,48 @@ static void test_default_map_chunk_bounds(void) {
(unsigned int)chunks, (unsigned int)largest_chunk); (unsigned int)chunks, (unsigned int)largest_chunk);
} }
static void test_authoritative_trails_and_visibility(void) {
maze32_maze_t maze;
open_test_grid(&maze);
maze.starts[1] = (maze32_cell_t){2U, 1U};
maze32_game_t game = initialized_game(&maze);
maze32_game_set_phase(&game, MAZE32_GAME_PHASE_ACTIVE);
uint32_t sequence = 0U;
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST,
200U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_ACCEPTED);
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_WEST,
200U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_ACCEPTED);
maze32_view_t observer;
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &observer) ==
MAZE32_GAME_RESULT_ACCEPTED);
uint16_t origin = index_of(1U, 1U);
int found = 0;
for (uint16_t index = 0U; index < observer.cell_count; ++index) {
if (observer.cells[index].cell_index == origin) {
assert(observer.cells[index].trail_east == 3U);
found = 1;
}
}
assert(found);
maze32_view_t player;
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &player) ==
MAZE32_GAME_RESULT_ACCEPTED);
for (uint16_t index = 0U; index < player.cell_count; ++index) {
assert(player.cells[index].trail_east <= 3U && player.cells[index].trail_south <= 3U);
}
puts("authoritative trail ownership and visibility: PASS");
}
int main(void) { int main(void) {
test_movement_cooldown_and_ordering(); test_movement_cooldown_and_ordering();
test_bounded_queue(); test_bounded_queue();
test_shared_cell_meeting(); test_shared_cell_meeting();
test_visibility_and_serialization(); test_visibility_and_serialization();
test_default_map_chunk_bounds(); test_default_map_chunk_bounds();
test_authoritative_trails_and_visibility();
puts("game core tests: PASS"); puts("game core tests: PASS");
return 0; return 0;
} }
+47 -1
View File
@@ -39,8 +39,11 @@ static void test_protocol_schema(void) {
const char* restart = "{\"v\":1,\"type\":\"restart\",\"seq\":5,\"payload\":{}}"; const char* restart = "{\"v\":1,\"type\":\"restart\",\"seq\":5,\"payload\":{}}";
assert(maze32_protocol_parse(restart, strlen(restart), &message) == MAZE32_PROTOCOL_OK); assert(maze32_protocol_parse(restart, strlen(restart), &message) == MAZE32_PROTOCOL_OK);
assert(message.type == MAZE32_MESSAGE_RESTART); assert(message.type == MAZE32_MESSAGE_RESTART);
const char* leave = "{\"v\":1,\"type\":\"leave\",\"seq\":6,\"payload\":{}}";
assert(maze32_protocol_parse(leave, strlen(leave), &message) == MAZE32_PROTOCOL_OK);
assert(message.type == MAZE32_MESSAGE_LEAVE);
const char* diagonal = const char* diagonal =
"{\"v\":1,\"type\":\"move\",\"seq\":6,\"payload\":{\"direction\":\"northwest\"}}"; "{\"v\":1,\"type\":\"move\",\"seq\":7,\"payload\":{\"direction\":\"northwest\"}}";
assert(maze32_protocol_parse(diagonal, strlen(diagonal), &message) == assert(maze32_protocol_parse(diagonal, strlen(diagonal), &message) ==
MAZE32_PROTOCOL_INVALID_PAYLOAD); MAZE32_PROTOCOL_INVALID_PAYLOAD);
@@ -129,9 +132,52 @@ static void test_lobby_limits_restoration_and_authority(void) {
puts("lobby repeated-match reset: PASS (128 cycles)"); puts("lobby repeated-match reset: PASS (128 cycles)");
} }
static void test_explicit_leave_releases_only_departing_session(void) {
maze32_lobby_t lobby;
maze32_lobby_init(&lobby);
maze32_session_t* player_1 = NULL;
maze32_session_t* player_2 = NULL;
maze32_session_t* observer = NULL;
assert(maze32_lobby_join_player(&lobby, 10, "Первый", TOKEN_1, &player_1) ==
MAZE32_LOBBY_OK);
assert(maze32_lobby_join_player(&lobby, 11, "Второй", TOKEN_2, &player_2) ==
MAZE32_LOBBY_OK);
assert(maze32_lobby_join_observer(&lobby, 20, OBSERVER_TOKENS[0], &observer) ==
MAZE32_LOBBY_OK);
maze32_role_t role = MAZE32_ROLE_NONE;
unsigned int player_slot = 0U;
assert(maze32_lobby_leave(&lobby, 10, &role, &player_slot));
assert(role == MAZE32_ROLE_PLAYER && player_slot == 1U);
assert(maze32_lobby_session_for_connection(&lobby, 10) == NULL);
assert(maze32_lobby_session_for_connection(&lobby, 11) == player_2);
assert(maze32_lobby_session_for_connection(&lobby, 20) == observer);
assert(maze32_lobby_restore(&lobby, 30, TOKEN_1, &player_1) == MAZE32_LOBBY_INVALID_TOKEN);
assert(maze32_lobby_join_player(&lobby, 30, "Новый первый", OBSERVER_TOKENS[1], &player_1) ==
MAZE32_LOBBY_OK);
assert(player_1->player_slot == 1U);
assert(!maze32_lobby_leave(&lobby, 10, NULL, NULL));
assert(maze32_lobby_leave(&lobby, 11, &role, &player_slot));
assert(role == MAZE32_ROLE_PLAYER && player_slot == 2U);
assert(maze32_lobby_session_for_connection(&lobby, 30) == player_1);
assert(maze32_lobby_session_for_connection(&lobby, 20) == observer);
assert(maze32_lobby_restore(&lobby, 31, TOKEN_2, &player_2) == MAZE32_LOBBY_INVALID_TOKEN);
assert(maze32_lobby_join_player(&lobby, 31, "Новый второй", OBSERVER_TOKENS[2], &player_2) ==
MAZE32_LOBBY_OK);
assert(player_2->player_slot == 2U);
assert(maze32_lobby_leave(&lobby, 20, &role, &player_slot));
assert(role == MAZE32_ROLE_OBSERVER && player_slot == 0U);
assert(maze32_lobby_session_for_connection(&lobby, 30) == player_1);
assert(maze32_lobby_session_for_connection(&lobby, 31) == player_2);
puts("explicit leave session revocation and slot reuse: PASS");
}
int main(void) { int main(void) {
test_protocol_schema(); test_protocol_schema();
test_lobby_limits_restoration_and_authority(); test_lobby_limits_restoration_and_authority();
test_explicit_leave_releases_only_departing_session();
puts("lobby and protocol tests: PASS"); puts("lobby and protocol tests: PASS");
return 0; return 0;
} }
+7
View File
@@ -43,3 +43,10 @@ test("runtime hedge sprite remains local and contains approved runtime symbols",
} }
assert.ok(zlib.gzipSync(runtime, {level: 9}).length < 4096); assert.ok(zlib.gzipSync(runtime, {level: 9}).length < 4096);
}); });
test("permanent hedge breaches leave a 60 percent clear centre", () => {
for (const identifier of ["hedge-breach-1", "hedge-breach-2", "hedge-breach-3"]) {
const symbol = runtime.match(new RegExp(`<g id="${identifier}">([\\s\\S]*?)</g>`))[1];
assert.match(symbol, /fill="#11131a" d="M32 0h96v40H32Z"/);
}
});
+144 -3
View File
@@ -103,6 +103,29 @@ test("latency correlation is bounded and only completes matching authoritative v
assert.equal(tracker.pointerSnapshot().length, player.LATENCY_SAMPLE_LIMIT); 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", () => { test("player map cells enlarge small projections without exceeding a readable bound", () => {
const sevenBySeven = player.playerCellSize(396, 730, 7, 7); const sevenBySeven = player.playerCellSize(396, 730, 7, 7);
const fourByFour = player.playerCellSize(396, 730, 4, 4); const fourByFour = player.playerCellSize(396, 730, 4, 4);
@@ -132,6 +155,19 @@ test("onboarding has bounded, replayable steps", () => {
assert.equal(player.nextOnboardingStep(7), null); assert.equal(player.nextOnboardingStep(7), null);
}); });
test("countdown completion leaves help closed until the player requests it", () => {
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const activeView = script.match(/function renderSnapshot\(snapshot\) \{([\s\S]*?)\n \}/)[1];
assert.match(activeView, /setScreen\("match"\)/);
assert.match(activeView, /setDirectionalPadEnabled\(snapshot\.phase === "active"/);
assert.match(activeView, /else hideModal\(\);/);
assert.doesNotMatch(activeView, /showOnboarding\(0\)/);
assert.match(script, /byId\("settings-help"\)\.addEventListener\("click", \(\) => \{ closeSettings\(false\); showOnboarding\(0\); \}\)/);
assert.match(script, /byId\("onboarding-skip"\)\.addEventListener\("click", hideOnboarding\)/);
assert.match(script, /if \(next == null\) hideOnboarding\(\)/);
});
test("avatar control resets safely and retains accessible alternatives", () => { test("avatar control resets safely and retains accessible alternatives", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8"); const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
@@ -149,7 +185,7 @@ test("avatar control resets safely and retains accessible alternatives", () => {
assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/); assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/);
assert.match(html, /id="avatar-control"/); assert.match(html, /id="avatar-control"/);
assert.match(html, /aria-label="Тянуть героя для движения"/); assert.match(html, /aria-label="Тянуть героя для движения"/);
assert.match(script, /onboarding-replay/); assert.match(script, /settings-help/);
assert.match(html, /id="onboarding-dots"[\s\S]*?<span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span>/); assert.match(html, /id="onboarding-dots"[\s\S]*?<span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span>/);
}); });
@@ -203,6 +239,28 @@ test("mobile directional pad is explicit, bounded, and resettable", () => {
assert.match(script, /state\.moveFlow\.request\(direction, element\)/); assert.match(script, /state\.moveFlow\.request\(direction, element\)/);
}); });
test("mobile welcome and lobby screens scroll while an active match remains fixed", () => {
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const mobileRules = css.slice(
css.indexOf("@media (max-width: 720px)"),
css.indexOf("@media (min-width: 721px)")
);
assert.doesNotMatch(mobileRules, /html, body \{[^}]*overflow: hidden/);
assert.doesNotMatch(mobileRules, /^ #app \{[^}]*height: 100dvh[^}]*overflow: hidden/m);
assert.match(mobileRules, /body\.match-active \{ height: 100%; overflow: hidden; \}/);
assert.match(mobileRules, /body\.match-active #app \{ height: 100dvh; min-height: 0; overflow: hidden; \}/);
assert.match(mobileRules, /#app \{ padding: max\(8px, env\(safe-area-inset-top\)\).*max\(8px, env\(safe-area-inset-bottom\)\)/);
assert.match(css, /\.match-screen \{[^}]*overflow: hidden/);
assert.match(css, /\.map-shell \{[^}]*touch-action: none/);
assert.match(script, /documentObject\.body\.classList\.toggle\("match-active", name === "match"\)/);
for (const viewport of [[412, 915], [412, 916], [320, 568], [568, 320]]) {
assert.ok(viewport[0] > 0 && viewport[1] > 0);
}
});
test("frontend name suggestions randomize both words and remain bounded", () => { test("frontend name suggestions randomize both words and remain bounded", () => {
const picks = [3, 4]; const picks = [3, 4];
const suggestion = player.randomSuggestedName([], () => picks.shift()); const suggestion = player.randomSuggestedName([], () => picks.shift());
@@ -281,14 +339,31 @@ test("hedge edges are canonical, bounded, and deterministic", () => {
assert.equal(player.hedgeKeyForMove(0, 2, "east"), "v:1:0"); assert.equal(player.hedgeKeyForMove(0, 2, "east"), "v:1:0");
}); });
test("breakthrough travel is bounded and starts only from an opened authoritative edge", () => {
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(script, /function breakthroughDirection\(previous, snapshot\)/);
assert.match(script, /projectedHedgeEdges\(snapshot\.cells, snapshot\.width\)\.some\(edge => edge\.key === key && edge\.opened\)/);
assert.match(script, /if \(direction\) showBreakthroughTravel\(direction\);\n else stopBreakthroughTravel\(\);/);
assert.match(script, /for \(let index = 0; index < 4; index\+\+\)/);
assert.match(script, /setTimeout\(stopBreakthroughTravel, 480\)/);
assert.match(script, /if \(reduced\) stopBreakthroughTravel\(\);/);
assert.match(script, /if \(name !== "match"\) stopBreakthroughTravel\(\);/);
assert.match(css, /\.breakthrough-runner \{[^}]*pointer-events: none[^}]*480ms/);
assert.match(css, /@keyframes breakthrough-run/);
assert.match(css, /\[data-reduced-motion="true"\] \.breakthrough-runner/);
});
test("refresh contract restores only the token and waits for server projection", () => { test("refresh contract restores only the token and waits for server projection", () => {
const restore = player.createEnvelope("restore", 1, "restore-1", {token: "0123456789abcdef0123456789abcdef"}); const restore = player.createEnvelope("restore", 1, "restore-1", {token: "0123456789abcdef0123456789abcdef"});
assert.deepEqual(Object.keys(restore.payload), ["token"]); assert.deepEqual(Object.keys(restore.payload), ["token"]);
assert.equal("position" in restore.payload, false); assert.equal("position" in restore.payload, false);
assert.equal("inventory" in restore.payload, false); assert.equal("inventory" in restore.payload, false);
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); 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\(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", () => { test("player assets are local, Russian, accessible, and responsive", () => {
@@ -308,7 +383,7 @@ test("player assets are local, Russian, accessible, and responsive", () => {
assert.match(css, /\.dpad \{[^}]*grid-template-columns: repeat\(3, 58px\)/); assert.match(css, /\.dpad \{[^}]*grid-template-columns: repeat\(3, 58px\)/);
assert.match(html, /id="avatar-control"/); assert.match(html, /id="avatar-control"/);
assert.match(html, /id="onboarding"/); assert.match(html, /id="onboarding"/);
assert.match(html, /id="mute-toggle"/); assert.match(html, /id="settings-mute"/);
assert.match(css, /\.avatar-control \{[^}]*width: 96px; height: 96px/); assert.match(css, /\.avatar-control \{[^}]*width: 96px; height: 96px/);
assert.match(css, /\.avatar-handle \{[^}]*transition: transform/); assert.match(css, /\.avatar-handle \{[^}]*transition: transform/);
assert.match(css, /\.avatar-handle \{[^}]*background: transparent/); assert.match(css, /\.avatar-handle \{[^}]*background: transparent/);
@@ -319,6 +394,41 @@ test("player assets are local, Russian, accessible, and responsive", () => {
assert.match(icons, /id="maze"/); assert.match(icons, /id="maze"/);
}); });
test("one compact settings popover replaces standalone secondary controls", () => {
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="settings-toggle"[^>]*aria-controls="settings-menu"[^>]*aria-expanded="false"[^>]*aria-label="Открыть настройки"/);
for (const identifier of ["settings-menu", "details-text", "settings-mute", "settings-motion", "settings-help", "settings-wifi"]) {
assert.match(html, new RegExp(`id="${identifier}"`));
}
assert.doesNotMatch(html, /id="details-toggle"|id="details-panel"|id="mute-toggle"|id="motion-toggle"|id="onboarding-replay"/);
assert.doesNotMatch(html, /access-controls|access-button/);
assert.match(css, /\.settings-menu \{[^}]*max-height: calc\(100dvh/);
assert.match(css, /\.settings-item\[aria-pressed="true"\]/);
assert.match(script, /function openSettings\(\)/);
assert.match(script, /function closeSettings\(restoreFocus\)/);
assert.match(script, /event\.key === "Escape" && !byId\("settings-menu"\)\.hidden/);
assert.match(script, /!menu\.hidden && !menu\.contains\(event\.target\) && !trigger\.contains\(event\.target\)/);
assert.match(script, /\["settings-mute", "settings-motion", "settings-help"\]\.forEach\(id => \{ byId\(id\)\.hidden = name !== "match"; \}\)/);
assert.match(script, /button\.textContent = muted \? "Звук: выкл" : "Звук: вкл"/);
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", () => { test("reference-derived SVG gameplay symbols preserve local yellow and blue ownership", () => {
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8"); const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
@@ -380,3 +490,34 @@ test("finished matches expose only an owner restart intention", () => {
assert.match(script, /message\.type === "reset"/); assert.match(script, /message\.type === "reset"/);
assert.doesNotMatch(script, /restart[^\n]*(position|inventory|result):/); assert.doesNotMatch(script, /restart[^\n]*(position|inventory|result):/);
}); });
test("finished-player exit returns remaining browsers to the waiting lobby", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const gateway = fs.readFileSync(path.join(project, "components/maze32_web/maze32_gateway.c"), "utf8");
const leaveHandler = gateway.match(/if \(message->type == MAZE32_MESSAGE_LEAVE\) \{[\s\S]*?if \(message->type == MAZE32_MESSAGE_START\)/)[0];
assert.match(html, /id="settings-leave"/);
assert.match(html, /id="leave-confirmation"/);
assert.match(script, /\["countdown", "active", "finish_pending", "paused", "finished"\]\.includes\(phase\)/);
assert.match(script, /message\.type === "reset"[\s\S]*?hideModal\(\); setScreen\("lobby"\)/);
assert.match(script, /message\.payload\.command === "leave" && message\.requestId === state\.leaveRequest/);
assert.match(script, /windowObject\.localStorage\.removeItem\(TOKEN_KEY\)/);
assert.match(script, /setScreen\("welcome"\)/);
assert.match(leaveHandler, /leaving_role == MAZE32_ROLE_PLAYER &&\s*gateway->lobby\.phase != MAZE32_LOBBY_WAITING;/);
assert.doesNotMatch(leaveHandler, /gateway->game\.phase != MAZE32_GAME_PHASE_FINISHED/);
});
test("authoritative trail masks render bounded yellow, blue, and shared paths", () => {
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
assert.equal(player.snapshotIsValid({width: 2, height: 2, cells: [[0, 6, 0, 3, 0]], objects: []}), true);
assert.equal(player.snapshotIsValid({width: 2, height: 2, cells: [[0, 6, 0, 4, 0]], objects: []}), false);
assert.match(script, /function renderTrails\(/);
assert.match(script, /if \(owners & 1\) paths\[0\]\.push/);
assert.match(script, /if \(owners & 2\) paths\[1\]\.push/);
assert.match(script, /renderTrails\(maze, snapshot\.cells/);
assert.match(css, /\.trail-layer \{[^}]*pointer-events: none/);
assert.match(css, /\.trail-player-one \{ stroke: var\(--p1\); stroke-dasharray/);
assert.match(css, /\.trail-player-two \{ stroke: var\(--p2\); stroke-dasharray/);
});
+3 -3
View File
@@ -15,8 +15,8 @@
<g id="hedge-impact-3"><use href="#hedge-intact-1" transform="skewX(-4)"/><path fill="none" stroke="#765033" stroke-width="3" d="m71 28 11-18m1 16 11-13"/></g> <g id="hedge-impact-3"><use href="#hedge-intact-1" transform="skewX(-4)"/><path fill="none" stroke="#765033" stroke-width="3" d="m71 28 11-18m1 16 11-13"/></g>
<g id="hedge-impact-4"><use href="#hedge-intact-1" transform="translate(0 4) scale(1 .86)"/><path fill="#c7e68a" d="m61 2 8 3-3 6-8-3Zm13 7 8 3-3 6-8-3Zm14-4 8 3-3 6-8-3Z"/></g> <g id="hedge-impact-4"><use href="#hedge-intact-1" transform="translate(0 4) scale(1 .86)"/><path fill="#c7e68a" d="m61 2 8 3-3 6-8-3Zm13 7 8 3-3 6-8-3Zm14-4 8 3-3 6-8-3Z"/></g>
<g id="hedge-breakthrough"><use href="#hedge-intact-1"/><path fill="#11131a" stroke="#173d2a" stroke-width="2" d="M62 0h36v40H62Z"/><path fill="#765033" d="m60 6 10 8-4 5-11-8Zm40 12 10-8 5 5-10 9Z"/></g> <g id="hedge-breakthrough"><use href="#hedge-intact-1"/><path fill="#11131a" stroke="#173d2a" stroke-width="2" d="M62 0h36v40H62Z"/><path fill="#765033" d="m60 6 10 8-4 5-11-8Zm40 12 10-8 5 5-10 9Z"/></g>
<g id="hedge-breach-1"><use href="#hedge-breakthrough"/><path fill="#4f9b55" d="M44 28c8-7 14-5 18 0v8H42Zm56 0c7-6 13-5 18 0v8H98Z"/></g> <g id="hedge-breach-1"><use href="#hedge-intact-1"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#296443" d="M0 8c12-9 23-6 32 2v22c-11 6-22 4-32-3Zm128 2c11-8 22-11 32-2v21c-10 7-21 9-32 3Z"/><path fill="none" stroke="#765033" stroke-width="3" d="m8 30 17-15m127 15-17-15"/><path fill="#4f9b55" d="m27 30 6-5 5 7-7 4Zm99 2 7-7 6 5-5 6Z"/></g>
<g id="hedge-breach-2"><use href="#hedge-breakthrough"/><path fill="#296443" d="M35 25c11-8 18-4 27 3v10H33Zm65 3c10-7 18-11 27-3v13H98Z"/></g> <g id="hedge-breach-2"><use href="#hedge-intact-3"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#173d2a" d="M0 5c14-7 25-5 32 5v22c-10 5-22 4-32-2Zm128 5c8-10 19-12 32-5v25c-11 6-22 7-32 2Z"/><path fill="none" stroke="#765033" stroke-width="4" d="M4 32 21 9m7 25L15 19m141 13-17-23m-7 25 13-15"/><path fill="#8cc968" d="m25 28 7-6 5 7-8 5Zm98 1 7-7 6 6-7 5Z"/></g>
<g id="hedge-breach-3"><use href="#hedge-breakthrough"/><path fill="none" stroke="#765033" stroke-width="3" d="m38 33 17-12m50 12 17-12"/></g> <g id="hedge-breach-3"><use href="#hedge-intact-5"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#4f9b55" d="M0 10c10-10 22-8 32 0v21c-12 6-23 3-32-4Zm128 0c10-8 22-10 32 0v17c-10 9-21 10-32 4Z"/><path fill="none" stroke="#765033" stroke-width="3" d="M5 31 23 12m-9 22 14-13m132-10-18-19m9 22-14-13"/><path fill="#296443" d="m29 30 7-5 4 7-7 3Zm94 3 6-8 7 5-5 7Z"/></g>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

+1
View File
@@ -9,6 +9,7 @@
<symbol id="eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="currentColor"/></symbol> <symbol id="eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="currentColor"/></symbol>
<symbol id="arrow" viewBox="0 0 24 24"><path d="M12 2l7 9h-4v11H9V11H5l7-9Z" fill="currentColor"/></symbol> <symbol id="arrow" viewBox="0 0 24 24"><path d="M12 2l7 9h-4v11H9V11H5l7-9Z" fill="currentColor"/></symbol>
<symbol id="signal" viewBox="0 0 24 24"><path d="M3 9a14 14 0 0 1 18 0M6 13a9 9 0 0 1 12 0m-9 4a4 4 0 0 1 6 0" fill="none" stroke="currentColor" stroke-width="2.5"/><circle cx="12" cy="21" r="1.5" fill="currentColor"/></symbol> <symbol id="signal" viewBox="0 0 24 24"><path d="M3 9a14 14 0 0 1 18 0M6 13a9 9 0 0 1 12 0m-9 4a4 4 0 0 1 6 0" fill="none" stroke="currentColor" stroke-width="2.5"/><circle cx="12" cy="21" r="1.5" fill="currentColor"/></symbol>
<symbol id="gear" viewBox="0 0 24 24"><path d="M10 2h4l.7 3 2.6 1.1 2.7-1.5 2 3.5-2.2 2 .1 3 2.1 1.9-2 3.5-2.7-1.5-2.6 1.1-.7 3h-4l-.7-3-2.6-1.1-2.7 1.5-2-3.5 2.1-1.9.1-3-2.2-2 2-3.5 2.7 1.5L9.3 5Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.8"/></symbol>
<symbol id="radar" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 12 20 6" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="2" fill="currentColor"/></symbol> <symbol id="radar" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 12 20 6" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="2" fill="currentColor"/></symbol>
<symbol id="hourglass" viewBox="0 0 24 24"><path d="M6 3h12M6 21h12M7 4c0 5 4 5 5 8-1 3-5 3-5 8m10-16c0 5-4 5-5 8 1 3 5 3 5 8" fill="none" stroke="currentColor" stroke-width="2"/></symbol> <symbol id="hourglass" viewBox="0 0 24 24"><path d="M6 3h12M6 21h12M7 4c0 5 4 5 5 8-1 3-5 3-5 8m10-16c0 5-4 5-5 8 1 3 5 3 5 8" fill="none" stroke="currentColor" stroke-width="2"/></symbol>
<symbol id="sound" viewBox="0 0 24 24"><path d="M4 10h4l5-4v12l-5-4H4zM17 9c2 2 2 4 0 6M20 6c4 4 4 8 0 12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol> <symbol id="sound" viewBox="0 0 24 24"><path d="M4 10h4l5-4v12l-5-4H4zM17 9c2 2 2 4 0 6M20 6c4 4 4 8 0 12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

+13 -7
View File
@@ -13,9 +13,19 @@
<header class="topbar"> <header class="topbar">
<a class="brand" href="/" aria-label="maze32, на главную">MAZE<span>32</span></a> <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> <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>
<button class="icon-button" id="details-toggle" type="button" aria-controls="details-panel" aria-expanded="false"><svg aria-hidden="true"><use href="/icons.svg#signal"></use></svg><span class="sr-only">Открыть сведения о соединении</span></button> <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> </header>
<aside class="details-panel" id="details-panel" hidden><strong>Связь с коробочкой</strong><span id="details-text">Подключение ещё не готово.</span><a href="/provision">Настроить Wi‑Fi</a></aside> <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"> <section class="screen welcome-screen" id="welcome-screen" aria-labelledby="welcome-title">
<div class="hero-art" aria-hidden="true"><span class="pixel-bush bush-one"></span><span class="pixel-bush bush-two"></span><svg><use href="/icons.svg#maze"></use></svg></div> <div class="hero-art" aria-hidden="true"><span class="pixel-bush bush-one"></span><span class="pixel-bush bush-two"></span><svg><use href="/icons.svg#maze"></use></svg></div>
@@ -59,11 +69,6 @@
<div class="child-controls" aria-label="Игровые действия"> <div class="child-controls" aria-label="Игровые действия">
<button class="drop-key" id="drop-key" type="button" aria-label="Сбросить ключ" disabled><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><strong>OK</strong><span>бросить</span></button> <button class="drop-key" id="drop-key" type="button" aria-label="Сбросить ключ" disabled><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><strong>OK</strong><span>бросить</span></button>
</div> </div>
<div class="access-controls" aria-label="Настройки игры">
<button class="access-button" id="mute-toggle" type="button" aria-label="Выключить звук"><svg aria-hidden="true"><use href="/icons.svg#sound"></use></svg></button>
<button class="access-button" id="motion-toggle" type="button" aria-label="Уменьшить движение"><svg aria-hidden="true"><use href="/icons.svg#motion"></use></svg></button>
<button class="access-button" id="onboarding-replay" type="button" aria-label="Показать обучение"><svg aria-hidden="true"><use href="/icons.svg#help"></use></svg></button>
</div>
<nav class="dpad" aria-label="Управление движением"><button type="button" data-direction="north" aria-label="Двигаться вверх" disabled>▲</button><button type="button" data-direction="west" aria-label="Двигаться влево" disabled>◀</button><span class="dpad-center" aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо" disabled>▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз" disabled>▼</button></nav> <nav class="dpad" aria-label="Управление движением"><button type="button" data-direction="north" aria-label="Двигаться вверх" disabled>▲</button><button type="button" data-direction="west" aria-label="Двигаться влево" disabled>◀</button><span class="dpad-center" aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо" disabled>▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз" disabled>▼</button></nav>
<section class="onboarding" id="onboarding" hidden aria-label="Обучение игре"> <section class="onboarding" id="onboarding" hidden aria-label="Обучение игре">
<div class="onboarding-card" id="onboarding-card"><svg class="onboarding-icon" aria-hidden="true"><use id="onboarding-icon-use" href="/icons.svg#hand"></use></svg><h2 id="onboarding-title">Потяни кружок</h2><p id="onboarding-text">Кружок ведёт героя по лабиринту.</p><div class="onboarding-dots" id="onboarding-dots" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div><button id="onboarding-next" type="button" aria-label="Следующий шаг обучения">Дальше</button><button id="onboarding-skip" type="button" aria-label="Пропустить обучение">Пропустить</button></div> <div class="onboarding-card" id="onboarding-card"><svg class="onboarding-icon" aria-hidden="true"><use id="onboarding-icon-use" href="/icons.svg#hand"></use></svg><h2 id="onboarding-title">Потяни кружок</h2><p id="onboarding-text">Кружок ведёт героя по лабиринту.</p><div class="onboarding-dots" id="onboarding-dots" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div><button id="onboarding-next" type="button" aria-label="Следующий шаг обучения">Дальше</button><button id="onboarding-skip" type="button" aria-label="Пропустить обучение">Пропустить</button></div>
@@ -84,6 +89,7 @@
</main> </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> <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> <script src="/player.js" defer></script>
</body> </body>
</html> </html>
+33 -10
View File
@@ -36,14 +36,23 @@ 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 { 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); } .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; } .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); } .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="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); } .connection-pill[data-state="offline"] .signal { background: var(--disconnect); box-shadow: 0 0 10px var(--disconnect); }
.icon-button { width: 38px; height: 38px; padding: 8px; border: 1px solid var(--edge); background: var(--panel-deep); cursor: pointer; } .icon-button { width: 38px; height: 38px; padding: 8px; border: 1px solid var(--edge); background: var(--panel-deep); cursor: pointer; }
.icon-button svg { width: 100%; height: 100%; color: var(--aqua); } .icon-button svg { width: 100%; height: 100%; color: var(--aqua); }
.details-panel { position: absolute; z-index: 30; top: 60px; right: max(12px, env(safe-area-inset-right)); display: grid; gap: 6px; width: min(300px, calc(100% - 24px)); padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; } .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; }
.details-panel span { color: var(--muted); } .settings-details { color: var(--muted); line-height: 1.45; }
.details-panel a { color: var(--aqua); } .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); } .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; } .welcome-screen { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 500px); align-items: center; gap: clamp(24px, 6vw, 90px); padding: 5vh 4vw; }
@@ -114,6 +123,10 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.maze { position: absolute; left: 50%; top: 50%; display: grid; transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px))); } .maze { position: absolute; left: 50%; top: 50%; display: grid; transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px))); }
.cell { position: relative; width: var(--cell); height: var(--cell); background: #11131a; } .cell { position: relative; width: var(--cell); height: var(--cell); background: #11131a; }
.cell::after { content: ""; position: absolute; inset: 7px; background: #1c1b27; opacity: .55; } .cell::after { content: ""; position: absolute; inset: 7px; background: #1c1b27; opacity: .55; }
.trail-layer { position: absolute; z-index: 0; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.trail-path { fill: none; stroke-width: .065; stroke-linecap: round; opacity: .58; }
.trail-player-one { stroke: var(--p1); stroke-dasharray: .06 .20; }
.trail-player-two { stroke: var(--p2); stroke-dasharray: .03 .16; stroke-dashoffset: .08; }
.hedge-layer { position: absolute; z-index: 1; inset: 0; pointer-events: none; overflow: visible; } .hedge-layer { position: absolute; z-index: 1; inset: 0; pointer-events: none; overflow: visible; }
.hedge-edge { position: absolute; width: var(--cell); height: calc(var(--cell) * .2); left: calc(var(--hedge-x) * var(--cell)); top: calc(var(--hedge-y) * var(--cell) - var(--cell) * .1); transform-origin: 50% 50%; pointer-events: none; } .hedge-edge { position: absolute; width: var(--cell); height: calc(var(--cell) * .2); left: calc(var(--hedge-x) * var(--cell)); top: calc(var(--hedge-y) * var(--cell) - var(--cell) * .1); transform-origin: 50% 50%; pointer-events: none; }
.hedge-edge svg { display: block; width: 100%; height: 100%; overflow: visible; } .hedge-edge svg { display: block; width: 100%; height: 100%; overflow: visible; }
@@ -151,6 +164,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.maze.rate-limited { filter: brightness(.48) blur(2px); transition: filter .12s linear; } .maze.rate-limited { filter: brightness(.48) blur(2px); transition: filter .12s linear; }
[data-reduced-motion="true"] .maze[class*="impact-"] { animation: none; } [data-reduced-motion="true"] .maze[class*="impact-"] { animation: none; }
[data-reduced-motion="true"] .maze.rate-limited { transition: none; } [data-reduced-motion="true"] .maze.rate-limited { transition: none; }
[data-reduced-motion="true"] .breakthrough-runner, [data-reduced-motion="true"] .breakthrough-accent { display: none; }
[data-reduced-motion="true"] .wall-bump.show { border-width: 4px; } [data-reduced-motion="true"] .wall-bump.show { border-width: 4px; }
[data-reduced-motion="true"] .wall-bump.impact-north { border-top-color: var(--error); } [data-reduced-motion="true"] .wall-bump.impact-north { border-top-color: var(--error); }
[data-reduced-motion="true"] .wall-bump.impact-east { border-right-color: var(--error); } [data-reduced-motion="true"] .wall-bump.impact-east { border-right-color: var(--error); }
@@ -162,6 +176,15 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.avatar-handle { position: absolute; left: 50%; top: 50%; width: 52px; height: 52px; display: grid; place-items: center; border: 3px solid currentColor; border-radius: 50%; background: transparent; color: var(--p1); box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead236; font-size: 1.25rem; font-weight: 1000; transform: translate(-50%, -50%); transition: transform .12s steps(3, end); } .avatar-handle { position: absolute; left: 50%; top: 50%; width: 52px; height: 52px; display: grid; place-items: center; border: 3px solid currentColor; border-radius: 50%; background: transparent; color: var(--p1); box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead236; font-size: 1.25rem; font-weight: 1000; transform: translate(-50%, -50%); transition: transform .12s steps(3, end); }
.player-two-mode .avatar-handle { color: var(--p2); } .player-two-mode .avatar-handle { color: var(--p2); }
.avatar-control.active .avatar-handle { transition: none; } .avatar-control.active .avatar-handle { transition: none; }
.avatar-control.breakthrough-travelling { opacity: 0; }
.breakthrough-runner { position: absolute; z-index: 9; left: var(--player-focal-x, 50%); top: var(--player-focal-y, 50%); width: 52px; height: 52px; display: grid; place-items: center; color: var(--p1); pointer-events: none; animation: breakthrough-run 480ms steps(6, end) forwards; }
.breakthrough-runner.player-two { color: var(--p2); }
.breakthrough-runner svg { width: 100%; height: 100%; padding: 5%; border: 3px solid currentColor; background: #0b0712; box-shadow: 3px 3px 0 #000; }
.breakthrough-accent { position: absolute; width: 8px; height: 8px; border: 2px solid #c7e68a; background: var(--plant); box-shadow: 1px 1px 0 #173d2a; animation: breakthrough-accent 480ms steps(5, end) forwards; }
.accent-1 { left: 4px; top: 6px; }
.accent-2 { right: 2px; top: 12px; background: var(--aqua); }
.accent-3 { left: 12px; bottom: 1px; background: #8cc968; }
.accent-4 { right: 9px; bottom: 5px; background: var(--warning); }
.avatar-player-icon { width: 74%; height: 74%; } .avatar-player-icon { width: 74%; height: 74%; }
.avatar-direction-icon { position: absolute; width: 19px; height: 19px; right: -13px; top: -13px; display: none; padding: 2px; border: 1px solid currentColor; background: #08050b; color: var(--aqua); } .avatar-direction-icon { position: absolute; width: 19px; height: 19px; right: -13px; top: -13px; display: none; padding: 2px; border: 1px solid currentColor; background: #08050b; color: var(--aqua); }
.avatar-control[data-direction] .avatar-direction-icon { display: block; } .avatar-control[data-direction] .avatar-direction-icon { display: block; }
@@ -173,10 +196,6 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.avatar-control[data-speed="slow"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead270; } .avatar-control[data-speed="slow"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead270; }
.child-controls { position: absolute; z-index: 12; inset: auto 0 12px; display: flex; align-items: end; justify-content: space-between; padding: 0 16px; pointer-events: none; } .child-controls { position: absolute; z-index: 12; inset: auto 0 12px; display: flex; align-items: end; justify-content: space-between; padding: 0 16px; pointer-events: none; }
.child-controls > * { pointer-events: auto; } .child-controls > * { pointer-events: auto; }
.access-controls { position: absolute; z-index: 14; top: 92px; right: 8px; display: grid; gap: 6px; }
.access-button { width: 42px; height: 42px; display: grid; place-items: center; border: 2px solid var(--aqua); border-radius: 50%; background: #102a30e8; color: var(--aqua); box-shadow: 3px 3px 0 #020506; }
.access-button svg { width: 24px; height: 24px; }
.access-button[aria-pressed="true"] { color: var(--warning); border-color: var(--warning); }
.onboarding { position: absolute; z-index: 30; inset: 0; display: grid; place-items: center; padding: 18px; background: #08050bd9; } .onboarding { position: absolute; z-index: 30; inset: 0; display: grid; place-items: center; padding: 18px; background: #08050bd9; }
.onboarding-card { width: min(340px, 92%); padding: 20px; border: 3px solid var(--aqua); background: var(--panel-deep); box-shadow: 7px 7px 0 #020103; text-align: center; } .onboarding-card { width: min(340px, 92%); padding: 20px; border: 3px solid var(--aqua); background: var(--panel-deep); box-shadow: 7px 7px 0 #020103; text-align: center; }
.onboarding-icon { width: 104px; height: 104px; color: var(--aqua); animation: guide-hand 1.2s ease-in-out infinite; } .onboarding-icon { width: 104px; height: 104px; color: var(--aqua); animation: guide-hand 1.2s ease-in-out infinite; }
@@ -252,8 +271,9 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
@media (max-width: 720px) { @media (max-width: 720px) {
:root { --cell: 46px; } :root { --cell: 46px; }
html, body { height: 100%; overflow: hidden; } body.match-active { height: 100%; overflow: hidden; }
#app { height: 100dvh; min-height: 0; overflow: hidden; padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); } #app { padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); }
body.match-active #app { height: 100dvh; min-height: 0; overflow: hidden; }
.welcome-screen { grid-template-columns: 1fr; padding: 28px 2px; } .welcome-screen { grid-template-columns: 1fr; padding: 28px 2px; }
.hero-art { display: none; } .hero-art { display: none; }
.welcome-panel { padding: 22px 18px; } .welcome-panel { padding: 22px 18px; }
@@ -277,10 +297,11 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.map-shell { inset: 82px 0 0; } .map-shell { inset: 82px 0 0; }
.child-controls { bottom: max(14px, env(safe-area-inset-bottom)); padding: 0 10px; } .child-controls { bottom: max(14px, env(safe-area-inset-bottom)); padding: 0 10px; }
.drop-key { position: relative; top: auto; right: auto; bottom: auto; width: 78px; height: 78px; } .drop-key { position: relative; top: auto; right: auto; bottom: auto; width: 78px; height: 78px; }
.access-controls { top: 88px; right: 8px; } .settings-menu { top: max(52px, calc(env(safe-area-inset-top) + 52px)); }
.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 { 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; } .dpad button { min-width: 62px; min-height: 62px; font-size: 1.3rem; }
.connection-pill { font-size: .58rem; } .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; } .connection-pill #connection-text { max-width: 145px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
} }
@@ -321,3 +342,5 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
@keyframes modal-in { from { opacity: 0; transform: scale(.82); } } @keyframes modal-in { from { opacity: 0; transform: scale(.82); } }
@keyframes count { 50% { transform: scale(1.08); } } @keyframes count { 50% { transform: scale(1.08); } }
@keyframes guide-hand { 50% { transform: translate(18px, -12px); } } @keyframes guide-hand { 50% { transform: translate(18px, -12px); } }
@keyframes breakthrough-run { 0% { transform: translate(calc(-50% + var(--breakthrough-start-x)), calc(-50% + var(--breakthrough-start-y))) scale(.78, 1.16); } 42% { transform: translate(calc(-50% + var(--breakthrough-mid-x)), calc(-50% + var(--breakthrough-mid-y))) scale(1.16, .78); } 100% { transform: translate(-50%, -50%) scale(1); } }
@keyframes breakthrough-accent { 0% { opacity: 0; transform: translate(0, 0) scale(.4); } 25% { opacity: 1; } 100% { opacity: 0; transform: translate(var(--breakthrough-start-x, 12px), -18px) scale(1.35); } }
+286 -20
View File
@@ -2,6 +2,7 @@
"use strict"; "use strict";
const TOKEN_KEY = "maze32.session.v1"; const TOKEN_KEY = "maze32.session.v1";
const NAME_KEY = "maze32.player-name.v1";
const MAX_VIEW_CHUNKS = 90; const MAX_VIEW_CHUNKS = 90;
const MAX_VIEW_WIDTH = 32; const MAX_VIEW_WIDTH = 32;
const MAX_VIEW_HEIGHT = 32; const MAX_VIEW_HEIGHT = 32;
@@ -16,6 +17,9 @@
const AVATAR_FAST_REPEAT_MS = 170; const AVATAR_FAST_REPEAT_MS = 170;
const DIRECTIONAL_PAD_REPEAT_MS = 170; const DIRECTIONAL_PAD_REPEAT_MS = 170;
const LATENCY_SAMPLE_LIMIT = 256; 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([ const ONBOARDING_STEPS = Object.freeze([
Object.freeze({icon: "hand", title: "Потяни героя", text: "Потяни героя — лабиринт поедет под ним."}), Object.freeze({icon: "hand", title: "Потяни героя", text: "Потяни героя — лабиринт поедет под ним."}),
Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}), Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}),
@@ -249,6 +253,50 @@
pointerSnapshot() { return this.pointerSamples.slice(); } 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) { function createEnvelope(type, sequence, requestId, payload) {
return {v: 1, type, seq: sequence, requestId, payload: payload || {}}; return {v: 1, type, seq: sequence, requestId, payload: payload || {}};
} }
@@ -258,7 +306,7 @@
return false; return false;
} }
const cellLimit = snapshot.width * snapshot.height; const cellLimit = snapshot.width * snapshot.height;
return snapshot.cells.every(cell => Array.isArray(cell) && cell.length === 3 && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 15 && Number.isInteger(cell[2]) && cell[2] >= 0 && cell[2] <= 15) && snapshot.objects.every(object => Array.isArray(object) && object.length === 4 && Number.isInteger(object[3]) && object[3] >= 0 && object[3] < cellLimit); return snapshot.cells.every(cell => Array.isArray(cell) && (cell.length === 3 || cell.length === 5) && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 15 && Number.isInteger(cell[2]) && cell[2] >= 0 && cell[2] <= 15 && (cell.length === 3 || (Number.isInteger(cell[3]) && cell[3] >= 0 && cell[3] <= 3 && Number.isInteger(cell[4]) && cell[4] >= 0 && cell[4] <= 3))) && snapshot.objects.every(object => Array.isArray(object) && object.length === 4 && Number.isInteger(object[3]) && object[3] >= 0 && object[3] < cellLimit);
} }
class SnapshotAssembler { class SnapshotAssembler {
@@ -355,7 +403,7 @@
function boot(documentObject, windowObject) { function boot(documentObject, windowObject) {
const byId = id => documentObject.getElementById(id); 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}, 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)); state.moveFlow = new LatestIntentFlow((direction, element) => sendMoveNow(direction, element));
function showNotice(text, kind, priority, persistent, key) { function showNotice(text, kind, priority, persistent, key) {
@@ -375,10 +423,14 @@
windowObject.clearTimeout(state.effects.impactTimer); windowObject.clearTimeout(state.effects.impactTimer);
windowObject.clearTimeout(state.effects.rateTimer); windowObject.clearTimeout(state.effects.rateTimer);
windowObject.clearTimeout(state.effects.breakthroughTimer); windowObject.clearTimeout(state.effects.breakthroughTimer);
windowObject.clearTimeout(state.effects.travelTimer);
state.effects.impactTimer = null; state.effects.impactTimer = null;
state.effects.rateTimer = null; state.effects.rateTimer = null;
state.effects.breakthroughTimer = null; state.effects.breakthroughTimer = null;
state.effects.travelTimer = null;
state.hedge.openedKeys.clear(); state.hedge.openedKeys.clear();
documentObject.querySelectorAll(".breakthrough-runner").forEach(runner => runner.remove());
byId("avatar-control").classList.remove("breakthrough-travelling");
byId("maze").classList.remove("impact-north", "impact-east", "impact-south", "impact-west", "rate-limited"); byId("maze").classList.remove("impact-north", "impact-east", "impact-south", "impact-west", "rate-limited");
byId("wall-bump").classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west"); byId("wall-bump").classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west");
documentObject.querySelectorAll(".cell.bush-impact").forEach(cell => cell.classList.remove("bush-impact", "bush-stage-1", "bush-stage-2", "bush-stage-3", "bush-stage-4")); documentObject.querySelectorAll(".cell.bush-impact").forEach(cell => cell.classList.remove("bush-impact", "bush-stage-1", "bush-stage-2", "bush-stage-3", "bush-stage-4"));
@@ -417,6 +469,62 @@
byId("lobby-screen").hidden = name !== "lobby"; byId("lobby-screen").hidden = name !== "lobby";
byId("match-screen").hidden = name !== "match"; byId("match-screen").hidden = name !== "match";
byId("observer-screen").hidden = name !== "observer"; 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);
}
function closeSettings(restoreFocus) {
const menu = byId("settings-menu");
if (menu.hidden) return;
menu.hidden = true;
const trigger = byId("settings-toggle");
trigger.setAttribute("aria-expanded", "false");
if (restoreFocus) trigger.focus();
}
function openSettings() {
const menu = byId("settings-menu");
menu.hidden = false;
byId("settings-toggle").setAttribute("aria-expanded", "true");
const firstAction = menu.querySelector(".settings-item:not([hidden])");
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", "finished"].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) { function renderWelcomeHeadline(headline) {
@@ -432,9 +540,51 @@
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически."; byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
if (connectionState === "offline") { resetPlayerControls(); state.moveDirections.clear(); showNotice("Связь ушла гулять. Ждём её здесь.", "error", 3, true, "offline"); } if (connectionState === "offline") { resetPlayerControls(); state.moveDirections.clear(); showNotice("Связь ушла гулять. Ждём её здесь.", "error", 3, true, "offline"); }
else if (state.rail.persistent) showNotice("Связь вернулась! Кусты снова в игре.", "success", 3, false, "online"); 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()); renderWelcomeHeadline(randomLoginHeadline());
byId("player-name").value = windowObject.localStorage.getItem(NAME_KEY) || "";
function beginFeedback() { function beginFeedback() {
if (state.feedbackReady) return; if (state.feedbackReady) return;
@@ -480,6 +630,7 @@
} }
const sequence = ++state.clientSequence; const sequence = ++state.clientSequence;
const requestId = `${type}-${sequence}`; const requestId = `${type}-${sequence}`;
if (type === "heartbeat" && !state.quality.begin(requestId)) return null;
state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload))); state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload)));
setPending(requestId, element); setPending(requestId, element);
if (type === "move") { state.latency.sent(requestId); state.moveDirections.set(requestId, payload.direction); } if (type === "move") { state.latency.sent(requestId); state.moveDirections.set(requestId, payload.direction); }
@@ -523,6 +674,45 @@
return edge.opened ? `hedge-breach-${hedgeVariant(edge, 3)}` : `hedge-intact-${hedgeVariant(edge, 6)}`; return edge.opened ? `hedge-breach-${hedgeVariant(edge, 3)}` : `hedge-intact-${hedgeVariant(edge, 6)}`;
} }
function breakthroughDirection(previous, snapshot) {
if (!previous || previous.role === "observer" || snapshot.role === "observer" || previous.width !== snapshot.width) return null;
const delta = snapshot.self - previous.self;
const directions = {[-snapshot.width]: "north", [1]: "east", [snapshot.width]: "south", [-1]: "west"};
const direction = directions[delta];
if (!direction || (direction === "east" && previous.self % snapshot.width === snapshot.width - 1) || (direction === "west" && previous.self % snapshot.width === 0)) return null;
const key = hedgeKeyForMove(previous.self, snapshot.width, direction);
return projectedHedgeEdges(snapshot.cells, snapshot.width).some(edge => edge.key === key && edge.opened) ? direction : null;
}
function stopBreakthroughTravel() {
windowObject.clearTimeout(state.effects.travelTimer);
state.effects.travelTimer = null;
documentObject.querySelectorAll(".breakthrough-runner").forEach(runner => runner.remove());
byId("avatar-control").classList.remove("breakthrough-travelling");
}
function showBreakthroughTravel(direction) {
stopBreakthroughTravel();
if (!direction || state.reducedMotion) return;
const mapShell = byId("map-shell");
const cellSize = Number.parseFloat(byId("maze").style.getPropertyValue("--cell"));
if (!Number.isFinite(cellSize)) return;
const offsets = {north: {x: 0, y: cellSize}, east: {x: -cellSize, y: 0}, south: {x: 0, y: -cellSize}, west: {x: cellSize, y: 0}};
const runner = icon("player", `breakthrough-runner player-${state.session && state.session.slot === 2 ? "two" : "one"}`);
runner.style.setProperty("--breakthrough-start-x", `${offsets[direction].x}px`);
runner.style.setProperty("--breakthrough-start-y", `${offsets[direction].y}px`);
runner.style.setProperty("--breakthrough-mid-x", `${offsets[direction].x * .42}px`);
runner.style.setProperty("--breakthrough-mid-y", `${offsets[direction].y * .42}px`);
for (let index = 0; index < 4; index++) {
const accent = documentObject.createElement("span");
accent.className = `breakthrough-accent accent-${index + 1}`;
runner.appendChild(accent);
}
mapShell.appendChild(runner);
byId("avatar-control").classList.add("breakthrough-travelling");
state.effects.travelTimer = windowObject.setTimeout(stopBreakthroughTravel, 480);
}
function renderHedgeEdges(maze, cells, width, minX, minY) { function renderHedgeEdges(maze, cells, width, minX, minY) {
const edges = projectedHedgeEdges(cells, width); const edges = projectedHedgeEdges(cells, width);
const openedKeys = new Set(edges.filter(edge => edge.opened).map(edge => edge.key)); const openedKeys = new Set(edges.filter(edge => edge.opened).map(edge => edge.key));
@@ -591,6 +781,34 @@
return slot === 2 ? {name: "Синий герой", keyName: "синего игрока", owner: "two"} : {name: "Жёлтый герой", keyName: "жёлтого игрока", owner: "one"}; return slot === 2 ? {name: "Синий герой", keyName: "синего игрока", owner: "two"} : {name: "Жёлтый герой", keyName: "жёлтого игрока", owner: "one"};
} }
function renderTrails(maze, cells, width, minX, minY, columns, rows) {
const layer = documentObject.createElementNS("http://www.w3.org/2000/svg", "svg");
layer.classList.add("trail-layer");
layer.setAttribute("viewBox", `0 0 ${columns} ${rows}`);
layer.setAttribute("preserveAspectRatio", "none");
layer.setAttribute("aria-hidden", "true");
const paths = [[], []];
cells.forEach(cell => {
if (!Array.isArray(cell) || cell.length !== 5) return;
const x = cell[0] % width - minX;
const y = Math.floor(cell[0] / width) - minY;
[[cell[3], x + 1, y], [cell[4], x, y + 1]].forEach(([owners, endX, endY]) => {
if (!owners) return;
const segment = `M ${x + .5} ${y + .5} L ${endX + .5} ${endY + .5}`;
if (owners & 1) paths[0].push(segment);
if (owners & 2) paths[1].push(segment);
});
});
paths.forEach((segments, owner) => {
if (!segments.length) return;
const path = documentObject.createElementNS("http://www.w3.org/2000/svg", "path");
path.classList.add("trail-path", owner === 0 ? "trail-player-one" : "trail-player-two");
path.setAttribute("d", segments.join(" "));
layer.appendChild(path);
});
maze.appendChild(layer);
}
function renderMap(snapshot) { function renderMap(snapshot) {
const maze = byId("maze"); const maze = byId("maze");
maze.replaceChildren(); maze.replaceChildren();
@@ -626,6 +844,7 @@
if (data.index === snapshot.opponent) addPawn(cell, slot === 1 ? 2 : 1, true); if (data.index === snapshot.opponent) addPawn(cell, slot === 1 ? 2 : 1, true);
maze.appendChild(cell); maze.appendChild(cell);
}); });
renderTrails(maze, snapshot.cells, snapshot.width, minX, minY, columns, rows);
renderHedgeEdges(maze, snapshot.cells, snapshot.width, minX, minY); renderHedgeEdges(maze, snapshot.cells, snapshot.width, minX, minY);
byId("map-placeholder").hidden = true; byId("map-placeholder").hidden = true;
} }
@@ -635,6 +854,8 @@
renderObserverSnapshot(snapshot); renderObserverSnapshot(snapshot);
return; return;
} }
const previous = state.snapshot;
const direction = breakthroughDirection(previous, snapshot);
state.snapshot = snapshot; state.snapshot = snapshot;
setScreen("match"); setScreen("match");
const slot = state.session && state.session.slot ? state.session.slot : 1; const slot = state.session && state.session.slot ? state.session.slot : 1;
@@ -656,15 +877,14 @@
byId("compass-stat").dataset.direction = hint; byId("compass-stat").dataset.direction = hint;
byId("compass-arrow").style.transform = `rotate(${HINT_ANGLES[hint]}deg)`; byId("compass-arrow").style.transform = `rotate(${HINT_ANGLES[hint]}deg)`;
renderMap(snapshot); renderMap(snapshot);
if (direction) showBreakthroughTravel(direction);
else stopBreakthroughTravel();
if (Number.isInteger(snapshot.stateSequence)) { if (Number.isInteger(snapshot.stateSequence)) {
windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence)); windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence));
} }
if (snapshot.phase === "paused") { resetPlayerControls(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); } if (snapshot.phase === "paused") { resetPlayerControls(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result); else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
else { else hideModal();
hideModal();
if (!state.onboardingSeen) showOnboarding(0);
}
} }
function applyObserverTransform() { function applyObserverTransform() {
@@ -709,6 +929,7 @@
}); });
maze.appendChild(cell); maze.appendChild(cell);
}); });
renderTrails(maze, snapshot.cells, snapshot.width, 0, 0, snapshot.width, snapshot.height);
renderHedgeEdges(maze, snapshot.cells, snapshot.width, 0, 0); renderHedgeEdges(maze, snapshot.cells, snapshot.width, 0, 0);
players.forEach((player, index) => { players.forEach((player, index) => {
const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`; const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`;
@@ -787,7 +1008,9 @@
clearPending(message.requestId); clearPending(message.requestId);
if (message.type === "session") { if (message.type === "session") {
state.session = message.payload; windowObject.localStorage.setItem(TOKEN_KEY, message.payload.token); 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"); showNotice(message.payload.role === "player" ? `Слот P${message.payload.slot} закреплён за этим браузером.` : "Вы в зрительном зале. Руками не трогаем!", "success");
scheduleHeartbeat(0);
if (state.lobby) renderLobby(); if (state.lobby) renderLobby();
} else if (message.type === "lobby") { state.lobby = message.payload; renderLobby(); } else if (message.type === "lobby") { state.lobby = message.payload; renderLobby();
} else if (message.type === "countdown") { showCountdown(message.payload); } else if (message.type === "countdown") { showCountdown(message.payload);
@@ -796,7 +1019,16 @@
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success"); } else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
} else if (message.type === "match_result") { renderResult(message.payload.result); } 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 === "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") { } else if (message.type === "error") {
const direction = state.moveDirections.get(message.requestId); const direction = state.moveDirections.get(message.requestId);
state.moveDirections.delete(message.requestId); state.moveDirections.delete(message.requestId);
@@ -814,7 +1046,7 @@
state.socket = new windowObject.WebSocket(`${scheme}://${windowObject.location.host}/ws`); 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.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.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"); state.socket.onerror = () => showNotice("Связь чихнула. Пробуем снова…", "error");
} }
@@ -851,6 +1083,7 @@
function resetPlayerControls() { function resetPlayerControls() {
resetAvatarControl(); resetAvatarControl();
resetDirectionalPad(); resetDirectionalPad();
stopBreakthroughTravel();
} }
function resetAvatarControl() { function resetAvatarControl() {
@@ -904,22 +1137,32 @@
function setMuted(muted) { function setMuted(muted) {
state.muted = muted; state.muted = muted;
const button = byId("mute-toggle"); const button = byId("settings-mute");
button.setAttribute("aria-pressed", String(muted)); button.setAttribute("aria-pressed", String(muted));
button.setAttribute("aria-label", muted ? "Включить звук" : "Выключить звук"); button.textContent = muted ? "Звук: выкл" : "Звук: вкл";
button.title = muted ? "Включить звук" : "Выключить звук";
button.querySelector("use").setAttribute("href", muted ? "/icons.svg#sound" : "/icons.svg#sound");
} }
function setReducedMotion(reduced) { function setReducedMotion(reduced) {
state.reducedMotion = reduced; state.reducedMotion = reduced;
if (reduced) stopBreakthroughTravel();
documentObject.documentElement.dataset.reducedMotion = String(reduced); documentObject.documentElement.dataset.reducedMotion = String(reduced);
byId("motion-toggle").setAttribute("aria-pressed", String(reduced)); const button = byId("settings-motion");
byId("motion-toggle").setAttribute("aria-label", reduced ? "Включить движение" : "Уменьшить движение"); button.setAttribute("aria-pressed", String(reduced));
button.textContent = reduced ? "Движение: выкл" : "Движение: вкл";
} }
documentObject.addEventListener("pointerdown", beginFeedback, {once: true}); documentObject.addEventListener("pointerdown", beginFeedback, {once: true});
documentObject.addEventListener("keydown", event => { 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);
return;
}
if (event.target instanceof windowObject.HTMLInputElement || byId("match-screen").hidden) return; if (event.target instanceof windowObject.HTMLInputElement || byId("match-screen").hidden) return;
beginFeedback(); beginFeedback();
const direction = directionFromKey(event.code); if (direction) { event.preventDefault(); move(direction, null); } const direction = directionFromKey(event.code); if (direction) { event.preventDefault(); move(direction, null); }
@@ -951,7 +1194,14 @@
}; };
windowObject.addEventListener("resize", refreshPlayerFocalPoint); windowObject.addEventListener("resize", refreshPlayerFocalPoint);
windowObject.addEventListener("orientationchange", 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 => { documentObject.querySelectorAll("[data-direction]").forEach(button => {
button.addEventListener("pointerdown", event => { button.addEventListener("pointerdown", event => {
if (button.disabled) return; if (button.disabled) return;
@@ -977,9 +1227,21 @@
}); });
}); });
byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key"))); byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key")));
byId("mute-toggle").addEventListener("click", () => setMuted(!state.muted)); byId("settings-toggle").addEventListener("click", () => {
byId("motion-toggle").addEventListener("click", () => setReducedMotion(!state.reducedMotion)); if (byId("settings-menu").hidden) openSettings();
byId("onboarding-replay").addEventListener("click", () => showOnboarding(0)); else closeSettings(false);
});
byId("settings-mute").addEventListener("click", () => setMuted(!state.muted));
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", () => { byId("onboarding-next").addEventListener("click", () => {
const next = nextOnboardingStep(state.onboardingStep); const next = nextOnboardingStep(state.onboardingStep);
if (next == null) hideOnboarding(); if (next == null) hideOnboarding();
@@ -1004,7 +1266,11 @@
const stopObserverDrag = event => { if (state.observer.dragging && state.observer.dragging.id === event.pointerId) { state.observer.dragging = null; byId("observer-stage").classList.remove("dragging"); } }; const stopObserverDrag = event => { if (state.observer.dragging && state.observer.dragging.id === event.pointerId) { state.observer.dragging = null; byId("observer-stage").classList.remove("dragging"); } };
byId("observer-stage").addEventListener("pointerup", stopObserverDrag); byId("observer-stage").addEventListener("pointerup", stopObserverDrag);
byId("observer-stage").addEventListener("pointercancel", stopObserverDrag); byId("observer-stage").addEventListener("pointercancel", stopObserverDrag);
byId("details-toggle").addEventListener("click", () => { const panel = byId("details-panel"); panel.hidden = !panel.hidden; byId("details-toggle").setAttribute("aria-expanded", String(!panel.hidden)); }); documentObject.addEventListener("pointerdown", event => {
const menu = byId("settings-menu");
const trigger = byId("settings-toggle");
if (!menu.hidden && !menu.contains(event.target) && !trigger.contains(event.target)) closeSettings(false);
});
byId("modal-action").addEventListener("click", () => { byId("modal-action").addEventListener("click", () => {
if (state.session && state.session.role === "player" && state.session.slot === 1 && state.snapshot && state.snapshot.phase === "finished") { if (state.session && state.session.role === "player" && state.session.slot === 1 && state.snapshot && state.snapshot.phase === "finished") {
send("restart", {}, byId("modal-action")); send("restart", {}, byId("modal-action"));
@@ -1016,7 +1282,7 @@
return {state, handleMessage, move, resetAvatarControl, latencySamples: () => state.latency.snapshot(), pointerFeedbackSamples: () => state.latency.pointerSnapshot()}; 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; if (typeof module !== "undefined" && module.exports) module.exports = api;
root.Maze32Player = api; root.Maze32Player = api;
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); }); if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });