Compare commits
6 Commits
07eaed9c93
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3518f8a152 | |||
| 8ad856b931 | |||
| b6d484dbc9 | |||
| 44433aee9c | |||
| 8a37412416 | |||
| 720cd18b6e |
+22
-1
@@ -195,6 +195,16 @@ stateDiagram-v2
|
||||
During `Paused`, movement commands are rejected and move-counted effects do not
|
||||
advance. During `FinishPending`, the first exit is retained while the server
|
||||
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
|
||||
|
||||
@@ -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
|
||||
current role projection. Its deterministic local SVG selection is presentation
|
||||
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
|
||||
authoritative `self` cell aligns with the responsive local focal point. That
|
||||
|
||||
@@ -216,6 +216,18 @@ Browser controls must support:
|
||||
state.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ extern "C" {
|
||||
#define MAZE32_GAME_MAX_OBJECTS \
|
||||
(6U + 2U * MAZE32_MAZE_MAX_LANDMARK_PAIRS + 2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS)
|
||||
#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_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_RUNTIME_BUDGET_MS 50U
|
||||
|
||||
@@ -163,6 +165,7 @@ typedef struct {
|
||||
maze32_game_exit_t exits[MAZE32_GAME_PLAYER_COUNT];
|
||||
uint8_t consumed_artifacts[MAZE32_GAME_MAX_ARTIFACTS];
|
||||
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];
|
||||
uint8_t command_head;
|
||||
uint8_t command_count;
|
||||
@@ -189,6 +192,8 @@ typedef struct {
|
||||
uint16_t cell_index;
|
||||
uint8_t passages;
|
||||
uint8_t opened_passages;
|
||||
uint8_t trail_east;
|
||||
uint8_t trail_south;
|
||||
} maze32_view_cell_t;
|
||||
|
||||
typedef struct {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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) {
|
||||
player->perseverance_hits = 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);
|
||||
} else {
|
||||
maze32_cell_t origin = player->position;
|
||||
maze32_passage_t passage = direction_passage(command.direction);
|
||||
if (!game_has_passage(game, player->position, passage)) {
|
||||
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;
|
||||
}
|
||||
player->position = moved_cell(player->position, command.direction);
|
||||
mark_trail_edge(game, player_index, origin, command.direction);
|
||||
accept_player_move(player, command.submitted_ms);
|
||||
processed.moved = 1U;
|
||||
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].passages = game_passages(game, cell);
|
||||
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++;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
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,
|
||||
cell->opened_passages)) {
|
||||
cell->opened_passages, cell->trail_east, cell->trail_south)) {
|
||||
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,
|
||||
const char* token, maze32_session_t** session);
|
||||
void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id);
|
||||
int maze32_lobby_leave(maze32_lobby_t* lobby, int connection_id, maze32_role_t* role,
|
||||
unsigned int* player_slot);
|
||||
maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id, uint64_t now_ms);
|
||||
int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms);
|
||||
void maze32_lobby_reset_match(maze32_lobby_t* lobby);
|
||||
|
||||
@@ -22,6 +22,7 @@ typedef enum {
|
||||
MAZE32_MESSAGE_MOVE,
|
||||
MAZE32_MESSAGE_DROP_KEY,
|
||||
MAZE32_MESSAGE_RESTART,
|
||||
MAZE32_MESSAGE_LEAVE,
|
||||
MAZE32_MESSAGE_HEARTBEAT,
|
||||
} maze32_message_type_t;
|
||||
|
||||
|
||||
@@ -167,6 +167,30 @@ void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id) {
|
||||
}
|
||||
}
|
||||
|
||||
int maze32_lobby_leave(maze32_lobby_t* lobby, int connection_id, maze32_role_t* role,
|
||||
unsigned int* player_slot) {
|
||||
if (lobby == NULL) {
|
||||
return 0;
|
||||
}
|
||||
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||
maze32_session_t* session = &lobby->sessions[index];
|
||||
if (session->role != MAZE32_ROLE_NONE && session->connected &&
|
||||
session->connection_id == connection_id) {
|
||||
if (role != NULL) {
|
||||
*role = session->role;
|
||||
}
|
||||
if (player_slot != NULL) {
|
||||
*player_slot = session->player_slot;
|
||||
}
|
||||
memset(session, 0, sizeof(*session));
|
||||
session->connection_id = -1;
|
||||
lobby->revision++;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id,
|
||||
uint64_t now_ms) {
|
||||
if (lobby == NULL || lobby->phase != MAZE32_LOBBY_WAITING) {
|
||||
|
||||
@@ -193,6 +193,9 @@ static maze32_message_type_t message_type(const char* value) {
|
||||
if (strcmp(value, "restart") == 0) {
|
||||
return MAZE32_MESSAGE_RESTART;
|
||||
}
|
||||
if (strcmp(value, "leave") == 0) {
|
||||
return MAZE32_MESSAGE_LEAVE;
|
||||
}
|
||||
if (strcmp(value, "heartbeat") == 0) {
|
||||
return MAZE32_MESSAGE_HEARTBEAT;
|
||||
}
|
||||
|
||||
@@ -948,6 +948,39 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
||||
"Сначала выберите роль или восстановите сессию");
|
||||
return;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_LEAVE) {
|
||||
maze32_role_t leaving_role = session->role;
|
||||
int cancel_match =
|
||||
leaving_role == MAZE32_ROLE_PLAYER && gateway->lobby.phase != MAZE32_LOBBY_WAITING;
|
||||
char acknowledgement[192] = "";
|
||||
size_t length = 0U;
|
||||
char prefix[96];
|
||||
int written = snprintf(prefix, sizeof(prefix),
|
||||
"{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32 ",\"requestId\":",
|
||||
next_server_sequence(gateway));
|
||||
if (written >= 0 && (size_t)written < sizeof(prefix) &&
|
||||
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix) &&
|
||||
append_json_string(acknowledgement, sizeof(acknowledgement), &length,
|
||||
message->request_id) &&
|
||||
append_text(acknowledgement, sizeof(acknowledgement), &length,
|
||||
",\"payload\":{\"command\":\"leave\"}}")) {
|
||||
enqueue(gateway, socket_fd, acknowledgement);
|
||||
}
|
||||
if (cancel_match) {
|
||||
esp_timer_stop(gateway->countdown_timer);
|
||||
esp_timer_stop(gateway->reconnect_timer);
|
||||
esp_timer_stop(gateway->finish_timer);
|
||||
initialize_game(gateway);
|
||||
maze32_lobby_reset_match(&gateway->lobby);
|
||||
}
|
||||
maze32_lobby_leave(&gateway->lobby, socket_fd, NULL, NULL);
|
||||
if (cancel_match) {
|
||||
broadcast_match_event(gateway, "reset", "{}");
|
||||
}
|
||||
broadcast_snapshot(gateway);
|
||||
drain_queue(gateway);
|
||||
return;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_START) {
|
||||
if (session->role == MAZE32_ROLE_OBSERVER) {
|
||||
ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=start", socket_fd);
|
||||
@@ -977,18 +1010,29 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
||||
return;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_HEARTBEAT) {
|
||||
int64_t heartbeat_started_us = esp_timer_get_time();
|
||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
|
||||
size_t length = 0U;
|
||||
char prefix[128];
|
||||
char handling_text[32];
|
||||
int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32,
|
||||
next_server_sequence(gateway));
|
||||
if (written >= 0 && (size_t)written < sizeof(prefix) &&
|
||||
uint64_t handling_ms = (uint64_t)(esp_timer_get_time() - heartbeat_started_us) / 1000U;
|
||||
if (handling_ms > 1000U) {
|
||||
handling_ms = 1000U;
|
||||
}
|
||||
int handling_written =
|
||||
snprintf(handling_text, sizeof(handling_text), "%" PRIu64, handling_ms);
|
||||
if (written >= 0 && (size_t)written < sizeof(prefix) && handling_written >= 0 &&
|
||||
(size_t)handling_written < sizeof(handling_text) &&
|
||||
append_text(response, sizeof(response), &length, prefix) &&
|
||||
append_text(response, sizeof(response), &length, ",\"requestId\":") &&
|
||||
append_json_string(response, sizeof(response), &length, message->request_id) &&
|
||||
append_text(response, sizeof(response), &length,
|
||||
",\"payload\":{\"command\":\"heartbeat\"}}")) {
|
||||
enqueue(gateway, socket_fd, response);
|
||||
",\"payload\":{\"command\":\"heartbeat\",\"handlingMs\":") &&
|
||||
append_text(response, sizeof(response), &length, handling_text) &&
|
||||
append_text(response, sizeof(response), &length, "}}")) {
|
||||
enqueue_with_policy(gateway, socket_fd, response, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -14,6 +14,7 @@ browser timestamp from an ESP timestamp.
|
||||
| command to painted view | browser | `move` send to next frame after matching view render |
|
||||
| handler and send | ESP | existing `/diagnostics` maxima |
|
||||
| network round trip | client | ICMP and WebSocket heartbeat samples |
|
||||
| ESP heartbeat handling | ESP | bounded acknowledgement preparation interval |
|
||||
|
||||
The accepted command acknowledgement contains the authoritative `stateSequence`.
|
||||
The browser records a sample only after the complete view with that sequence is
|
||||
@@ -38,6 +39,10 @@ rendered and the next animation frame occurs. Samples are local-only, capped at
|
||||
and send maxima, queue high-water marks, observer view chunk count, and
|
||||
ICMP/WebSocket round trips. Attribute a delay only when the matching
|
||||
interval is elevated.
|
||||
7. For the compact quality indicator, record the rounded `Отклик`, `Связь`,
|
||||
and `ESP` values after the five-second heartbeat cadence has collected a
|
||||
fresh sample. `Связь` is the non-negative round-trip remainder, not a pure
|
||||
Wi-Fi measurement.
|
||||
|
||||
## Safety checks
|
||||
|
||||
|
||||
+33
-3
@@ -56,18 +56,48 @@ a maze boundary, and is recalculated on resize and rotation.
|
||||
|
||||
## 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
|
||||
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
|
||||
blocked-move text. Opened-passage bits in an ordinary cell projection render as
|
||||
a permanent trampled bush gap. Effects have one restartable timer and clear on
|
||||
pause, result, reset, disconnect, and control reset. Reduced motion is static.
|
||||
blocked-move text. A received opened-passage projection paired with an adjacent
|
||||
authoritative player move may show one 480 ms hedge-parting dash and four
|
||||
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
|
||||
received cells. It hashes each visible edge deterministically to choose the
|
||||
approved local SVG variant, so the browser neither retains unseen geometry nor
|
||||
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
|
||||
above the application top bar. One connection-loss message has highest priority
|
||||
and remains until a successful recovery; other messages deduplicate, replace
|
||||
|
||||
+11
-2
@@ -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
|
||||
players must be connected. Sessions are preserved while a new authoritative
|
||||
maze and game state are created.
|
||||
- `leave`: `{}`. Revokes only the authenticated sending session. A player
|
||||
leaving 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.
|
||||
|
||||
## Server messages
|
||||
@@ -52,7 +56,9 @@ is only echoed for correlation; it grants no authority.
|
||||
- `active`: the exact authoritative activation instant.
|
||||
- `view`: a bounded chunk of a role-specific authoritative game projection.
|
||||
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
|
||||
deadline.
|
||||
- `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.
|
||||
- `reset`: reports the authoritative return from a finished match to the
|
||||
waiting lobby.
|
||||
- `ack`: successful heartbeat correlation.
|
||||
- `ack`: successful heartbeat correlation. A heartbeat acknowledgement has
|
||||
`payload.command` set to `heartbeat` and a bounded integer
|
||||
`payload.handlingMs` measured only by the ESP monotonic clock while it
|
||||
prepares that acknowledgement.
|
||||
- `error`: a stable English `code` and Russian player-facing `message`.
|
||||
|
||||
An observer session can receive snapshots but cannot issue `start`, `move`, or
|
||||
|
||||
+32
-7
@@ -100,8 +100,24 @@ are not MVP requirements.
|
||||
- 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 icon with its color.
|
||||
- Connection details and the Wi-Fi settings link belong in a secondary panel,
|
||||
not in the persistent status row.
|
||||
- A single gear button beside the persistent Wi-Fi connection indicator opens
|
||||
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.
|
||||
- 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
|
||||
@@ -130,9 +146,13 @@ are not MVP requirements.
|
||||
- Server-approved breakthrough impacts one through four bend the visible bush
|
||||
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.
|
||||
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.
|
||||
- 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
|
||||
|
||||
@@ -152,8 +172,9 @@ are not MVP requirements.
|
||||
- Impact stages one through four retain the same wall centre while increasingly
|
||||
compressing foliage, bending twigs, and shedding a fixed small number of
|
||||
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
|
||||
both sides. They never imply movement beyond that one authoritative edge.
|
||||
at least 24 CSS pixels wide at 40-pixel scale, with two separated foliage
|
||||
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
|
||||
at 34, 40, and 46 CSS pixels. They contain no coordinates, objects,
|
||||
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
|
||||
projection replacement removes all obsolete edge nodes. One restartable
|
||||
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
|
||||
|
||||
|
||||
@@ -324,12 +324,48 @@ static void test_default_map_chunk_bounds(void) {
|
||||
(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) {
|
||||
test_movement_cooldown_and_ordering();
|
||||
test_bounded_queue();
|
||||
test_shared_cell_meeting();
|
||||
test_visibility_and_serialization();
|
||||
test_default_map_chunk_bounds();
|
||||
test_authoritative_trails_and_visibility();
|
||||
puts("game core tests: PASS");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -39,8 +39,11 @@ static void test_protocol_schema(void) {
|
||||
const char* restart = "{\"v\":1,\"type\":\"restart\",\"seq\":5,\"payload\":{}}";
|
||||
assert(maze32_protocol_parse(restart, strlen(restart), &message) == MAZE32_PROTOCOL_OK);
|
||||
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 =
|
||||
"{\"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) ==
|
||||
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)");
|
||||
}
|
||||
|
||||
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) {
|
||||
test_protocol_schema();
|
||||
test_lobby_limits_restoration_and_authority();
|
||||
test_explicit_leave_releases_only_departing_session();
|
||||
puts("lobby and protocol tests: PASS");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -43,3 +43,10 @@ test("runtime hedge sprite remains local and contains approved runtime symbols",
|
||||
}
|
||||
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
@@ -103,6 +103,29 @@ test("latency correlation is bounded and only completes matching authoritative v
|
||||
assert.equal(tracker.pointerSnapshot().length, player.LATENCY_SAMPLE_LIMIT);
|
||||
});
|
||||
|
||||
test("connection quality uses one correlated heartbeat and bounded local smoothing", () => {
|
||||
let clock = 0;
|
||||
const quality = new player.ConnectionQuality(() => clock);
|
||||
assert.equal(quality.begin("heartbeat-1"), true);
|
||||
assert.equal(quality.begin("heartbeat-2"), false);
|
||||
clock = 85;
|
||||
assert.equal(quality.acknowledge("wrong", 2), false);
|
||||
assert.equal(quality.acknowledge("heartbeat-1", 2), true);
|
||||
assert.deepEqual(quality.latest, {totalMs: 85, communicationMs: 83, handlingMs: 2, received: 85});
|
||||
for (let index = 0; index < player.QUALITY_SAMPLE_LIMIT + 2; index++) {
|
||||
clock += 10;
|
||||
assert.equal(quality.begin(`heartbeat-${index + 2}`), true);
|
||||
clock += 20;
|
||||
assert.equal(quality.acknowledge(`heartbeat-${index + 2}`, 1), true);
|
||||
}
|
||||
assert.equal(quality.samples.length, player.QUALITY_SAMPLE_LIMIT);
|
||||
assert.equal(quality.acknowledge("heartbeat-99", 1001), false);
|
||||
clock += player.HEARTBEAT_STALE_MS + 1;
|
||||
assert.equal(quality.status(), "stale");
|
||||
quality.reset();
|
||||
assert.equal(quality.status(), "unknown");
|
||||
});
|
||||
|
||||
test("player map cells enlarge small projections without exceeding a readable bound", () => {
|
||||
const sevenBySeven = player.playerCellSize(396, 730, 7, 7);
|
||||
const fourByFour = player.playerCellSize(396, 730, 4, 4);
|
||||
@@ -132,6 +155,19 @@ test("onboarding has bounded, replayable steps", () => {
|
||||
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", () => {
|
||||
const html = fs.readFileSync(path.join(project, "web/index.html"), "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(html, /id="avatar-control"/);
|
||||
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>/);
|
||||
});
|
||||
|
||||
@@ -203,6 +239,28 @@ test("mobile directional pad is explicit, bounded, and resettable", () => {
|
||||
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", () => {
|
||||
const picks = [3, 4];
|
||||
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");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const restore = player.createEnvelope("restore", 1, "restore-1", {token: "0123456789abcdef0123456789abcdef"});
|
||||
assert.deepEqual(Object.keys(restore.payload), ["token"]);
|
||||
assert.equal("position" in restore.payload, false);
|
||||
assert.equal("inventory" in restore.payload, false);
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
assert.equal((script.match(/localStorage\.setItem/g) || []).length, 1);
|
||||
assert.equal((script.match(/localStorage\.setItem/g) || []).length, 2);
|
||||
assert.match(script, /localStorage\.setItem\(TOKEN_KEY, message\.payload\.token\)/);
|
||||
assert.match(script, /localStorage\.setItem\(NAME_KEY, byId\("player-name"\)\.value\.slice\(0, 32\)\)/);
|
||||
});
|
||||
|
||||
test("player assets are local, Russian, accessible, and responsive", () => {
|
||||
@@ -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(html, /id="avatar-control"/);
|
||||
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-handle \{[^}]*transition: transform/);
|
||||
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"/);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "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.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
@@ -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-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-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-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-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-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-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-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>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -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="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="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="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>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.8 KiB |
+13
-7
@@ -13,9 +13,19 @@
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/" aria-label="maze32, на главную">MAZE<span>32</span></a>
|
||||
<div class="connection-pill" id="connection" data-state="connecting" role="status"><span class="signal" aria-hidden="true"></span><span id="connection-text">Ловим сигнал ESP…</span></div>
|
||||
<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>
|
||||
<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">
|
||||
<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="Игровые действия">
|
||||
<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 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>
|
||||
<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>
|
||||
@@ -84,6 +89,7 @@
|
||||
</main>
|
||||
|
||||
<div class="modal" id="modal" hidden role="dialog" aria-modal="true" aria-labelledby="modal-title"><div class="modal-card"><svg id="modal-icon" aria-hidden="true"><use href="/icons.svg#hourglass"></use></svg><p class="eyebrow" id="modal-kicker">Серверное объявление</p><h2 id="modal-title">Приготовились!</h2><div class="countdown" id="countdown" hidden>3</div><p id="modal-text">Один сервер — один честный момент.</p><button class="button primary" id="modal-action" type="button" hidden>Вернуться в лобби</button></div></div>
|
||||
<section class="modal" id="leave-confirmation" hidden role="dialog" aria-modal="true" aria-labelledby="leave-title"><div class="modal-card"><p class="eyebrow">Подтверждение</p><h2 id="leave-title">Выйти из игры?</h2><p id="leave-text"></p><div class="button-row"><button class="button primary" id="leave-accept" type="button">Выйти</button><button class="button" id="leave-cancel" type="button">Остаться</button></div></div></section>
|
||||
<script src="/player.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+33
-10
@@ -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 span { color: var(--aqua); }
|
||||
.connection-pill { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.quality-pill { display: flex; align-items: center; gap: 4px; min-width: 48px; color: var(--muted); font-size: .68rem; font-weight: 800; white-space: nowrap; }
|
||||
.quality-pill svg { width: 17px; height: 17px; }
|
||||
.quality-pill[data-state="good"] { color: var(--success); }
|
||||
.quality-pill[data-state="delayed"] { color: var(--warning); }
|
||||
.quality-pill[data-state="slow"], .quality-pill[data-state="offline"] { color: var(--error); }
|
||||
.signal { width: 9px; height: 9px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 10px var(--warning); }
|
||||
.connection-pill[data-state="online"] .signal { background: var(--success); box-shadow: 0 0 10px var(--success); }
|
||||
.connection-pill[data-state="offline"] .signal { background: var(--disconnect); box-shadow: 0 0 10px var(--disconnect); }
|
||||
.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); }
|
||||
.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; }
|
||||
.details-panel span { color: var(--muted); }
|
||||
.details-panel a { color: var(--aqua); }
|
||||
.settings-menu { position: fixed; z-index: 40; top: max(58px, calc(env(safe-area-inset-top) + 58px)); right: max(12px, env(safe-area-inset-right)); display: grid; gap: 8px; width: min(300px, calc(100% - 24px)); max-height: calc(100dvh - max(70px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom))); overflow-y: auto; padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; }
|
||||
.settings-details { color: var(--muted); line-height: 1.45; }
|
||||
.settings-quality { color: var(--muted); line-height: 1.45; }
|
||||
.settings-item { min-height: 42px; display: flex; align-items: center; justify-content: space-between; border: 1px solid var(--edge); background: #0c0815; padding: 8px 10px; color: var(--ink); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
|
||||
.settings-item:hover, .settings-item:focus-visible { border-color: var(--aqua); background: #164049; }
|
||||
.settings-item[aria-pressed="true"] { border-color: var(--warning); color: var(--warning); }
|
||||
.settings-exit { border-color: var(--error); color: var(--error); }
|
||||
|
||||
.screen { min-height: calc(100dvh - 90px); animation: screen-in .28s steps(5, end); }
|
||||
.welcome-screen { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 500px); align-items: center; gap: clamp(24px, 6vw, 90px); padding: 5vh 4vw; }
|
||||
@@ -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))); }
|
||||
.cell { position: relative; width: var(--cell); height: var(--cell); background: #11131a; }
|
||||
.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-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; }
|
||||
@@ -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; }
|
||||
[data-reduced-motion="true"] .maze[class*="impact-"] { animation: 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.impact-north { border-top-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); }
|
||||
.player-two-mode .avatar-handle { color: var(--p2); }
|
||||
.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-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; }
|
||||
@@ -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; }
|
||||
.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; }
|
||||
.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-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; }
|
||||
@@ -252,8 +271,9 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
:root { --cell: 46px; }
|
||||
html, body { 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)); }
|
||||
body.match-active { height: 100%; overflow: hidden; }
|
||||
#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; }
|
||||
.hero-art { display: none; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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 button { min-width: 62px; min-height: 62px; font-size: 1.3rem; }
|
||||
.connection-pill { font-size: .58rem; }
|
||||
.quality-pill { min-width: 40px; font-size: .6rem; }
|
||||
.connection-pill #connection-text { max-width: 145px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
}
|
||||
|
||||
@@ -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 count { 50% { transform: scale(1.08); } }
|
||||
@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
@@ -2,6 +2,7 @@
|
||||
"use strict";
|
||||
|
||||
const TOKEN_KEY = "maze32.session.v1";
|
||||
const NAME_KEY = "maze32.player-name.v1";
|
||||
const MAX_VIEW_CHUNKS = 90;
|
||||
const MAX_VIEW_WIDTH = 32;
|
||||
const MAX_VIEW_HEIGHT = 32;
|
||||
@@ -16,6 +17,9 @@
|
||||
const AVATAR_FAST_REPEAT_MS = 170;
|
||||
const DIRECTIONAL_PAD_REPEAT_MS = 170;
|
||||
const LATENCY_SAMPLE_LIMIT = 256;
|
||||
const QUALITY_SAMPLE_LIMIT = 5;
|
||||
const HEARTBEAT_INTERVAL_MS = 5000;
|
||||
const HEARTBEAT_STALE_MS = 15000;
|
||||
const ONBOARDING_STEPS = Object.freeze([
|
||||
Object.freeze({icon: "hand", title: "Потяни героя", text: "Потяни героя — лабиринт поедет под ним."}),
|
||||
Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}),
|
||||
@@ -249,6 +253,50 @@
|
||||
pointerSnapshot() { return this.pointerSamples.slice(); }
|
||||
}
|
||||
|
||||
class ConnectionQuality {
|
||||
constructor(now) {
|
||||
this.now = typeof now === "function" ? now : () => root.performance.now();
|
||||
this.pending = null;
|
||||
this.samples = [];
|
||||
this.latest = null;
|
||||
this.level = "unknown";
|
||||
}
|
||||
|
||||
begin(requestId) {
|
||||
if (this.pending || !requestId) return false;
|
||||
this.pending = {requestId, sent: this.now()};
|
||||
return true;
|
||||
}
|
||||
|
||||
acknowledge(requestId, handlingMs) {
|
||||
if (!this.pending || this.now() - this.pending.sent > HEARTBEAT_STALE_MS || this.pending.requestId !== requestId || !Number.isInteger(handlingMs) || handlingMs < 0 || handlingMs > 1000) return false;
|
||||
const totalMs = Math.max(0, this.now() - this.pending.sent);
|
||||
this.pending = null;
|
||||
this.samples.push(totalMs);
|
||||
if (this.samples.length > QUALITY_SAMPLE_LIMIT) this.samples.shift();
|
||||
const ordered = this.samples.slice().sort((left, right) => left - right);
|
||||
const smoothedMs = ordered[Math.floor(ordered.length / 2)];
|
||||
this.latest = {totalMs: smoothedMs, communicationMs: Math.max(0, smoothedMs - handlingMs), handlingMs, received: this.now()};
|
||||
if (this.level === "good") this.level = smoothedMs > 175 ? "delayed" : "good";
|
||||
else if (this.level === "slow") this.level = smoothedMs <= 450 ? "delayed" : "slow";
|
||||
else if (smoothedMs <= 150) this.level = "good";
|
||||
else this.level = smoothedMs <= 500 ? "delayed" : "slow";
|
||||
return true;
|
||||
}
|
||||
|
||||
reset() { this.pending = null; this.samples = []; this.latest = null; this.level = "unknown"; }
|
||||
|
||||
expirePending() {
|
||||
if (this.pending && this.now() - this.pending.sent > HEARTBEAT_STALE_MS) this.pending = null;
|
||||
}
|
||||
|
||||
status() {
|
||||
if (!this.latest) return "unknown";
|
||||
if (this.now() - this.latest.received > HEARTBEAT_STALE_MS) return "stale";
|
||||
return this.level;
|
||||
}
|
||||
}
|
||||
|
||||
function createEnvelope(type, sequence, requestId, payload) {
|
||||
return {v: 1, type, seq: sequence, requestId, payload: payload || {}};
|
||||
}
|
||||
@@ -258,7 +306,7 @@
|
||||
return false;
|
||||
}
|
||||
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 {
|
||||
@@ -355,7 +403,7 @@
|
||||
|
||||
function boot(documentObject, windowObject) {
|
||||
const byId = id => documentObject.getElementById(id);
|
||||
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null, breakthroughTimer: null}, hedge: {openedKeys: new Set()}, moveDirections: new Map(), touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, startX: null, startY: null, direction: null, repeatTimer: null, repeatMs: null}, dpad: {pointerId: null, direction: null, repeatTimer: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
|
||||
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, heartbeatTimer: null, leaveRequest: null, noticeTimer: null, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null, breakthroughTimer: null, travelTimer: null}, hedge: {openedKeys: new Set()}, moveDirections: new Map(), touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), quality: new ConnectionQuality(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, startX: null, startY: null, direction: null, repeatTimer: null, repeatMs: null}, dpad: {pointerId: null, direction: null, repeatTimer: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
|
||||
state.moveFlow = new LatestIntentFlow((direction, element) => sendMoveNow(direction, element));
|
||||
|
||||
function showNotice(text, kind, priority, persistent, key) {
|
||||
@@ -375,10 +423,14 @@
|
||||
windowObject.clearTimeout(state.effects.impactTimer);
|
||||
windowObject.clearTimeout(state.effects.rateTimer);
|
||||
windowObject.clearTimeout(state.effects.breakthroughTimer);
|
||||
windowObject.clearTimeout(state.effects.travelTimer);
|
||||
state.effects.impactTimer = null;
|
||||
state.effects.rateTimer = null;
|
||||
state.effects.breakthroughTimer = null;
|
||||
state.effects.travelTimer = null;
|
||||
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("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"));
|
||||
@@ -417,6 +469,62 @@
|
||||
byId("lobby-screen").hidden = name !== "lobby";
|
||||
byId("match-screen").hidden = name !== "match";
|
||||
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) {
|
||||
@@ -432,9 +540,51 @@
|
||||
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
|
||||
if (connectionState === "offline") { resetPlayerControls(); state.moveDirections.clear(); showNotice("Связь ушла гулять. Ждём её здесь.", "error", 3, true, "offline"); }
|
||||
else if (state.rail.persistent) showNotice("Связь вернулась! Кусты снова в игре.", "success", 3, false, "online");
|
||||
renderQuality();
|
||||
}
|
||||
|
||||
function renderQuality() {
|
||||
const quality = state.quality.latest;
|
||||
const status = !state.socket || state.socket.readyState !== windowObject.WebSocket.OPEN ? "offline" : state.quality.status();
|
||||
const indicator = byId("quality");
|
||||
indicator.dataset.state = status;
|
||||
if (!quality || status === "unknown" || status === "stale" || status === "offline") {
|
||||
const label = status === "offline" ? "нет связи" : status === "stale" ? "устарело" : "неизвестно";
|
||||
byId("quality-text").textContent = status === "offline" ? "нет" : "—";
|
||||
indicator.setAttribute("aria-label", `Отклик: ${label}`);
|
||||
byId("quality-details").textContent = `Отклик: ${label}`;
|
||||
return;
|
||||
}
|
||||
const total = Math.round(quality.totalMs);
|
||||
const communication = Math.round(quality.communicationMs);
|
||||
const handling = Math.round(quality.handlingMs);
|
||||
byId("quality-text").textContent = `${total} мс`;
|
||||
indicator.setAttribute("aria-label", `Отклик ${total} мс. Связь ${communication} мс. ESP ${handling} мс.`);
|
||||
byId("quality-details").textContent = `Отклик: ${total} мс · Связь: ${communication} мс · ESP: ${handling} мс. Связь — остаток транспорта, не только Wi‑Fi.`;
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
windowObject.clearTimeout(state.heartbeatTimer);
|
||||
state.heartbeatTimer = null;
|
||||
state.quality.reset();
|
||||
renderQuality();
|
||||
}
|
||||
|
||||
function scheduleHeartbeat(delay) {
|
||||
windowObject.clearTimeout(state.heartbeatTimer);
|
||||
state.heartbeatTimer = windowObject.setTimeout(() => {
|
||||
state.quality.expirePending();
|
||||
if (documentObject.hidden || !state.session || state.quality.pending || !state.socket || state.socket.readyState !== windowObject.WebSocket.OPEN) {
|
||||
scheduleHeartbeat(HEARTBEAT_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
send("heartbeat", {}, null);
|
||||
scheduleHeartbeat(HEARTBEAT_INTERVAL_MS);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
renderWelcomeHeadline(randomLoginHeadline());
|
||||
byId("player-name").value = windowObject.localStorage.getItem(NAME_KEY) || "";
|
||||
|
||||
function beginFeedback() {
|
||||
if (state.feedbackReady) return;
|
||||
@@ -480,6 +630,7 @@
|
||||
}
|
||||
const sequence = ++state.clientSequence;
|
||||
const requestId = `${type}-${sequence}`;
|
||||
if (type === "heartbeat" && !state.quality.begin(requestId)) return null;
|
||||
state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload)));
|
||||
setPending(requestId, element);
|
||||
if (type === "move") { state.latency.sent(requestId); state.moveDirections.set(requestId, payload.direction); }
|
||||
@@ -523,6 +674,45 @@
|
||||
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) {
|
||||
const edges = projectedHedgeEdges(cells, width);
|
||||
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"};
|
||||
}
|
||||
|
||||
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) {
|
||||
const maze = byId("maze");
|
||||
maze.replaceChildren();
|
||||
@@ -626,6 +844,7 @@
|
||||
if (data.index === snapshot.opponent) addPawn(cell, slot === 1 ? 2 : 1, true);
|
||||
maze.appendChild(cell);
|
||||
});
|
||||
renderTrails(maze, snapshot.cells, snapshot.width, minX, minY, columns, rows);
|
||||
renderHedgeEdges(maze, snapshot.cells, snapshot.width, minX, minY);
|
||||
byId("map-placeholder").hidden = true;
|
||||
}
|
||||
@@ -635,6 +854,8 @@
|
||||
renderObserverSnapshot(snapshot);
|
||||
return;
|
||||
}
|
||||
const previous = state.snapshot;
|
||||
const direction = breakthroughDirection(previous, snapshot);
|
||||
state.snapshot = snapshot;
|
||||
setScreen("match");
|
||||
const slot = state.session && state.session.slot ? state.session.slot : 1;
|
||||
@@ -656,15 +877,14 @@
|
||||
byId("compass-stat").dataset.direction = hint;
|
||||
byId("compass-arrow").style.transform = `rotate(${HINT_ANGLES[hint]}deg)`;
|
||||
renderMap(snapshot);
|
||||
if (direction) showBreakthroughTravel(direction);
|
||||
else stopBreakthroughTravel();
|
||||
if (Number.isInteger(snapshot.stateSequence)) {
|
||||
windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence));
|
||||
}
|
||||
if (snapshot.phase === "paused") { resetPlayerControls(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
|
||||
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
|
||||
else {
|
||||
hideModal();
|
||||
if (!state.onboardingSeen) showOnboarding(0);
|
||||
}
|
||||
else hideModal();
|
||||
}
|
||||
|
||||
function applyObserverTransform() {
|
||||
@@ -709,6 +929,7 @@
|
||||
});
|
||||
maze.appendChild(cell);
|
||||
});
|
||||
renderTrails(maze, snapshot.cells, snapshot.width, 0, 0, snapshot.width, snapshot.height);
|
||||
renderHedgeEdges(maze, snapshot.cells, snapshot.width, 0, 0);
|
||||
players.forEach((player, index) => {
|
||||
const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`;
|
||||
@@ -787,7 +1008,9 @@
|
||||
clearPending(message.requestId);
|
||||
if (message.type === "session") {
|
||||
state.session = message.payload; windowObject.localStorage.setItem(TOKEN_KEY, message.payload.token);
|
||||
if (message.payload.role === "player") windowObject.localStorage.setItem(NAME_KEY, byId("player-name").value.slice(0, 32));
|
||||
showNotice(message.payload.role === "player" ? `Слот P${message.payload.slot} закреплён за этим браузером.` : "Вы в зрительном зале. Руками не трогаем!", "success");
|
||||
scheduleHeartbeat(0);
|
||||
if (state.lobby) renderLobby();
|
||||
} else if (message.type === "lobby") { state.lobby = message.payload; renderLobby();
|
||||
} else if (message.type === "countdown") { showCountdown(message.payload);
|
||||
@@ -796,7 +1019,16 @@
|
||||
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
|
||||
} else if (message.type === "match_result") { renderResult(message.payload.result);
|
||||
} else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); resetPlayerControls(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
|
||||
} else if (message.type === "ack") { state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); state.moveDirections.delete(message.requestId); state.moveFlow.complete(message.requestId); feedback(true);
|
||||
} else if (message.type === "ack") {
|
||||
if (message.payload && message.payload.command === "leave" && message.requestId === state.leaveRequest) {
|
||||
finishLeave();
|
||||
return;
|
||||
}
|
||||
if (message.payload && message.payload.command === "heartbeat") {
|
||||
if (state.quality.acknowledge(message.requestId, message.payload.handlingMs)) renderQuality();
|
||||
return;
|
||||
}
|
||||
state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); state.moveDirections.delete(message.requestId); state.moveFlow.complete(message.requestId); feedback(true);
|
||||
} else if (message.type === "error") {
|
||||
const direction = state.moveDirections.get(message.requestId);
|
||||
state.moveDirections.delete(message.requestId);
|
||||
@@ -814,7 +1046,7 @@
|
||||
state.socket = new windowObject.WebSocket(`${scheme}://${windowObject.location.host}/ws`);
|
||||
state.socket.onopen = () => { setConnection("online", "ESP на связи"); const token = windowObject.localStorage.getItem(TOKEN_KEY); if (token) send("restore", {token}, null); };
|
||||
state.socket.onmessage = event => { let message; try { message = JSON.parse(event.data); } catch (_) { showNotice("Лабиринт прислал каракули. Ждём понятный знак.", "error"); return; } handleMessage(message); };
|
||||
state.socket.onclose = () => { state.moveFlow.disconnect(); setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
|
||||
state.socket.onclose = () => { state.moveFlow.disconnect(); stopHeartbeat(); setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
|
||||
state.socket.onerror = () => showNotice("Связь чихнула. Пробуем снова…", "error");
|
||||
}
|
||||
|
||||
@@ -851,6 +1083,7 @@
|
||||
function resetPlayerControls() {
|
||||
resetAvatarControl();
|
||||
resetDirectionalPad();
|
||||
stopBreakthroughTravel();
|
||||
}
|
||||
|
||||
function resetAvatarControl() {
|
||||
@@ -904,22 +1137,32 @@
|
||||
|
||||
function setMuted(muted) {
|
||||
state.muted = muted;
|
||||
const button = byId("mute-toggle");
|
||||
const button = byId("settings-mute");
|
||||
button.setAttribute("aria-pressed", String(muted));
|
||||
button.setAttribute("aria-label", muted ? "Включить звук" : "Выключить звук");
|
||||
button.title = muted ? "Включить звук" : "Выключить звук";
|
||||
button.querySelector("use").setAttribute("href", muted ? "/icons.svg#sound" : "/icons.svg#sound");
|
||||
button.textContent = muted ? "Звук: выкл" : "Звук: вкл";
|
||||
}
|
||||
|
||||
function setReducedMotion(reduced) {
|
||||
state.reducedMotion = reduced;
|
||||
if (reduced) stopBreakthroughTravel();
|
||||
documentObject.documentElement.dataset.reducedMotion = String(reduced);
|
||||
byId("motion-toggle").setAttribute("aria-pressed", String(reduced));
|
||||
byId("motion-toggle").setAttribute("aria-label", reduced ? "Включить движение" : "Уменьшить движение");
|
||||
const button = byId("settings-motion");
|
||||
button.setAttribute("aria-pressed", String(reduced));
|
||||
button.textContent = reduced ? "Движение: выкл" : "Движение: вкл";
|
||||
}
|
||||
|
||||
documentObject.addEventListener("pointerdown", beginFeedback, {once: true});
|
||||
documentObject.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape" && !byId("leave-confirmation").hidden) {
|
||||
event.preventDefault();
|
||||
closeLeaveConfirmation();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && !byId("settings-menu").hidden) {
|
||||
event.preventDefault();
|
||||
closeSettings(true);
|
||||
return;
|
||||
}
|
||||
if (event.target instanceof windowObject.HTMLInputElement || byId("match-screen").hidden) return;
|
||||
beginFeedback();
|
||||
const direction = directionFromKey(event.code); if (direction) { event.preventDefault(); move(direction, null); }
|
||||
@@ -951,7 +1194,14 @@
|
||||
};
|
||||
windowObject.addEventListener("resize", refreshPlayerFocalPoint);
|
||||
windowObject.addEventListener("orientationchange", refreshPlayerFocalPoint);
|
||||
documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetPlayerControls(); });
|
||||
documentObject.addEventListener("visibilitychange", () => {
|
||||
if (documentObject.hidden) {
|
||||
resetPlayerControls();
|
||||
stopHeartbeat();
|
||||
} else if (state.session && state.socket && state.socket.readyState === windowObject.WebSocket.OPEN) {
|
||||
scheduleHeartbeat(0);
|
||||
}
|
||||
});
|
||||
documentObject.querySelectorAll("[data-direction]").forEach(button => {
|
||||
button.addEventListener("pointerdown", event => {
|
||||
if (button.disabled) return;
|
||||
@@ -977,9 +1227,21 @@
|
||||
});
|
||||
});
|
||||
byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key")));
|
||||
byId("mute-toggle").addEventListener("click", () => setMuted(!state.muted));
|
||||
byId("motion-toggle").addEventListener("click", () => setReducedMotion(!state.reducedMotion));
|
||||
byId("onboarding-replay").addEventListener("click", () => showOnboarding(0));
|
||||
byId("settings-toggle").addEventListener("click", () => {
|
||||
if (byId("settings-menu").hidden) openSettings();
|
||||
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", () => {
|
||||
const next = nextOnboardingStep(state.onboardingStep);
|
||||
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"); } };
|
||||
byId("observer-stage").addEventListener("pointerup", 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", () => {
|
||||
if (state.session && state.session.role === "player" && state.session.slot === 1 && state.snapshot && state.snapshot.phase === "finished") {
|
||||
send("restart", {}, byId("modal-action"));
|
||||
@@ -1016,7 +1282,7 @@
|
||||
return {state, handleMessage, move, resetAvatarControl, latencySamples: () => state.latency.snapshot(), pointerFeedbackSamples: () => state.latency.pointerSnapshot()};
|
||||
}
|
||||
|
||||
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_ADJECTIVES, NAME_NOUNS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, DIRECTIONAL_PAD_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, playerFocalPoint, playerFocalMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, hedgeEdgeKey, hedgeKeyForMove, hedgeVariant, projectedHedgeEdges, clampObserverZoom, observerFitScale, boot});
|
||||
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_ADJECTIVES, NAME_NOUNS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, QUALITY_SAMPLE_LIMIT, HEARTBEAT_INTERVAL_MS, HEARTBEAT_STALE_MS, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, DIRECTIONAL_PAD_REPEAT_MS, LatestIntentFlow, LatencyTracker, ConnectionQuality, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, playerFocalPoint, playerFocalMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, hedgeEdgeKey, hedgeKeyForMove, hedgeVariant, projectedHedgeEdges, clampObserverZoom, observerFitScale, boot});
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
root.Maze32Player = api;
|
||||
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });
|
||||
|
||||
Reference in New Issue
Block a user