Compare commits

..

14 Commits

28 changed files with 3932 additions and 212 deletions
+53 -5
View File
@@ -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
@@ -219,7 +229,8 @@ conceptual records include:
- `MazeConfig`: width, height, movement cooldown, object-density settings, and
balance thresholds;
- `Maze`: compact cell topology plus landmark identifiers;
- `Maze`: compact generated cell topology plus landmark identifiers;
- `MatchTopology`: fixed-size server-opened passage overlay for one match;
- `PlayerState`: slot, display name, session token reference, position,
inventory, vision effect, connection state, and last accepted move time;
- `KeyState`: owner and either a ground position or carrier;
@@ -315,10 +326,19 @@ The browser application has four presentation modes:
3. player match view;
4. observer full-map view.
Player controls support keyboard movement, touch swipes, and an explicit
drop-key action. The UI renders only server-provided visible state. The observer
view supports fitting, panning, or zooming the complete `32 x 32` map without
changing server authority.
Player controls support keyboard movement, avatar drag, touch swipes, an
explicit four-button directional pad, and an explicit drop-key action. Every
movement input uses the same bounded latest-intent path and sends only a
cardinal intention; the browser does not predict a movement result. The UI
renders only server-provided visible state. The observer view supports fitting,
panning, or zooming the complete `32 x 32` map without changing server
authority.
The browser renders Player 1 as yellow and Player 2 as blue. The matching key
and exit use the owner's color as a secondary identity signal, while distinct
SVG silhouettes and open/closed treatments identify object type and state. This
presentation mapping is local only and does not alter authoritative game or
projection data.
`docs/UI_STYLE.md` is the canonical browser presentation contract. The player
layout supports a minimum `412 x 916` CSS-pixel viewport. Observer mode requires
@@ -326,6 +346,34 @@ at least `1024 x 768` CSS pixels and presents a large-screen requirement message
below that threshold. Responsive layout changes presentation only; they never
change projection content, command validation, or server authority.
The ESP stores each player's bounded perseverance streak and a fixed-size
opened-passage overlay. It alone expires streaks at 3,000 ms, opens both passage
bits, and projects changed passages only in cells already visible to that role.
The browser may correlate a matching server-approved impact stage with an
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. 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
point is centered horizontally and 30% down the usable portrait map region;
compact landscape uses a left-biased unobstructed region. It changes no server
projection, visibility, or hidden-state handling.
All application assets are local and precompressed. Browser startup and play
must not depend on internet access, third-party CSS, remote fonts, icons,
analytics, or content services. The complete compressed web payload is limited
+33 -8
View File
@@ -109,8 +109,10 @@ approximately five minutes. There is no match time limit.
simultaneous turns.
- A small server-enforced minimum interval exists between accepted moves. Its
value is configurable for playtesting.
- Rejected movement into a wall does not change position and is not a
successful move.
- A server-accepted impact against the same closed interior wall from the same
cell and direction is tracked per player. The first four impacts do not move
the player; the fifth within three seconds of each previous impact opens the
passage in both cells and completes that move. Perimeter walls never open.
- Players do not block each other and may occupy the same cell.
- A meeting occurs when both players occupy the same cell.
- There are no attacks, health, damage, or other combat mechanics in MVP.
@@ -214,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
@@ -222,15 +236,26 @@ Browser controls must support:
`docs/UI_STYLE.md`.
- All player-facing UI copy, game messages, and browser errors are in Russian.
- The UI uses a near-black background, purple panels, turquoise interactive
accents, blue for Player 1, pink for Player 2, and plant-like green maze
walls. Important state is distinguished by icons, labels, outlines, or
shapes in addition to color.
accents, yellow for Player 1, blue for Player 2, and plant-like green maze
walls. Each player's matching key and exit use the same ownership color.
Important state is distinguished by icons, labels, outlines, or shapes in
addition to color; amber warnings and blue pause states use distinct
treatments so they cannot be confused with player identity.
- The player map consumes all space not required by fixed status and control
panels. The player view supports a minimum `412 x 916` CSS-pixel viewport
without page scrolling.
- Computers support keyboard movement and on-screen arcade controls. Tablets
support swipes and on-screen controls. Phones use directional swipes without
on-screen directional buttons.
- On portrait phones, the authoritative player focal point is horizontally
centered at approximately 30% of the usable map height, above the operating
hand zone. Compact landscape uses a stable left-biased focal point in the
largest unobstructed map area.
- Textual system notices use one Russian live message rail above the top bar.
A connection-loss notice remains there until recovery; movement blocked by a
wall and rate limiting use maze-only visual feedback and never show text.
- Computers and tablets support keyboard, swipes, and on-screen arcade
controls. Phones show an always-visible four-button directional pad as the
primary touch control while retaining avatar drag and directional swipes as
optional alternatives. The pad sends only existing orthogonal movement
intentions through the authoritative server path.
- An always-visible `OK` control with a key icon drops the carried key without
confirmation and exposes the Russian accessible label `Сбросить ключ`.
- Observer mode requires at least a `1024 x 768` CSS-pixel viewport, initially
+2162 -7
View File
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -10,6 +10,8 @@ extern "C" {
#endif
#define MAZE32_GAME_PLAYER_COUNT 2U
#define MAZE32_GAME_BREAKTHROUGH_HITS 5U
#define MAZE32_GAME_BREAKTHROUGH_TIMEOUT_MS 3000U
#define MAZE32_GAME_COMMAND_CAPACITY 16U
#define MAZE32_GAME_KEY_COUNT 2U
#define MAZE32_GAME_NO_KEY UINT8_MAX
@@ -20,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
@@ -101,6 +105,10 @@ typedef struct {
uint8_t exited;
uint8_t connected;
uint64_t reconnect_deadline_ms;
maze32_cell_t perseverance_cell;
maze32_game_direction_t perseverance_direction;
uint64_t perseverance_last_impact_ms;
uint8_t perseverance_hits;
} maze32_game_player_t;
typedef enum {
@@ -139,6 +147,8 @@ typedef struct {
uint8_t activated_artifact;
uint8_t opened_exit;
uint8_t outward_exit;
uint8_t breakthrough_hits;
uint8_t breakthrough_opened;
uint8_t finished;
uint8_t key;
uint8_t artifact;
@@ -154,6 +164,8 @@ typedef struct {
maze32_game_key_t keys[MAZE32_GAME_KEY_COUNT];
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;
@@ -179,6 +191,9 @@ typedef enum {
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 {
+131 -4
View File
@@ -20,6 +20,19 @@ static maze32_passage_t direction_passage(maze32_game_direction_t direction) {
return PASSAGES[direction];
}
static maze32_passage_t opposite_passage(maze32_passage_t passage) {
if (passage == MAZE32_PASSAGE_NORTH) {
return MAZE32_PASSAGE_SOUTH;
}
if (passage == MAZE32_PASSAGE_EAST) {
return MAZE32_PASSAGE_WEST;
}
if (passage == MAZE32_PASSAGE_SOUTH) {
return MAZE32_PASSAGE_NORTH;
}
return MAZE32_PASSAGE_EAST;
}
static maze32_cell_t moved_cell(maze32_cell_t cell, maze32_game_direction_t direction) {
if (direction == MAZE32_GAME_DIRECTION_NORTH) {
cell.y--;
@@ -45,6 +58,73 @@ static int cells_equal(maze32_cell_t first, maze32_cell_t second) {
return first.x == second.x && first.y == second.y;
}
static uint8_t game_passages(const maze32_game_t* game, maze32_cell_t cell) {
return (uint8_t)(game->maze->passages[cell_index(game->maze, cell)] |
game->opened_passages[cell_index(game->maze, cell)]);
}
static int game_has_passage(const maze32_game_t* game, maze32_cell_t cell,
maze32_passage_t passage) {
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;
}
static uint8_t record_wall_impact(maze32_game_player_t* player, maze32_cell_t cell,
maze32_game_direction_t direction, uint64_t submitted_ms) {
if (player->perseverance_hits == 0U || !cells_equal(player->perseverance_cell, cell) ||
player->perseverance_direction != direction ||
submitted_ms < player->perseverance_last_impact_ms ||
submitted_ms - player->perseverance_last_impact_ms >= MAZE32_GAME_BREAKTHROUGH_TIMEOUT_MS) {
player->perseverance_cell = cell;
player->perseverance_direction = direction;
player->perseverance_hits = 1U;
} else {
player->perseverance_hits++;
}
player->perseverance_last_impact_ms = submitted_ms;
return player->perseverance_hits;
}
static void reset_all_perseverance(maze32_game_t* game) {
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
reset_perseverance(&game->players[player]);
}
}
static int is_visible(maze32_cell_t center, maze32_cell_t cell, uint8_t radius) {
uint16_t dx = center.x > cell.x ? center.x - cell.x : cell.x - center.x;
uint16_t dy = center.y > cell.y ? center.y - cell.y : cell.y - center.y;
@@ -95,6 +175,9 @@ maze32_game_result_t maze32_game_init(maze32_game_t* game, const maze32_maze_t*
void maze32_game_set_phase(maze32_game_t* game, maze32_game_phase_t phase) {
if (game != NULL && phase >= MAZE32_GAME_PHASE_COUNTDOWN &&
phase <= MAZE32_GAME_PHASE_FINISHED) {
if (phase != MAZE32_GAME_PHASE_ACTIVE) {
reset_all_perseverance(game);
}
if (phase == MAZE32_GAME_PHASE_ACTIVE &&
(!game->players[0].connected || !game->players[1].connected)) {
game->phase_before_pause = MAZE32_GAME_PHASE_ACTIVE;
@@ -159,6 +242,7 @@ int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) {
game->paused_started_ms = 0U;
game->command_head = 0U;
game->command_count = 0U;
reset_all_perseverance(game);
game->state_sequence++;
return 1;
}
@@ -176,6 +260,7 @@ int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) {
game->phase = MAZE32_GAME_PHASE_FINISHED;
game->final_result = game->first_exited_player == 0U ? MAZE32_GAME_FINAL_PLAYER_ONE
: MAZE32_GAME_FINAL_PLAYER_TWO;
reset_all_perseverance(game);
game->state_sequence++;
return 1;
}
@@ -219,6 +304,7 @@ maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8
}
player->connected = 0U;
reset_perseverance(player);
player->reconnect_deadline_ms = now_ms > UINT64_MAX - MAZE32_GAME_RECONNECT_TIMEOUT_MS
? UINT64_MAX
: now_ms + MAZE32_GAME_RECONNECT_TIMEOUT_MS;
@@ -229,6 +315,7 @@ maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8
game->paused_started_ms = now_ms;
game->command_head = 0U;
game->command_count = 0U;
reset_all_perseverance(game);
}
game->state_sequence++;
return MAZE32_GAME_RESULT_ACCEPTED;
@@ -373,6 +460,7 @@ static void finish_outward_move(maze32_game_t* game, uint8_t player_index, uint6
} else {
game->phase = MAZE32_GAME_PHASE_FINISHED;
game->final_result = MAZE32_GAME_FINAL_DRAW;
reset_all_perseverance(game);
processed->finished = 1U;
}
}
@@ -452,6 +540,7 @@ maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) {
return processed;
}
if (direction_leaves_map(game->maze, player->position, command.direction)) {
reset_perseverance(player);
if (!cells_equal(player->position, game->maze->exits[player_index]) ||
!game->exits[player_index].open) {
processed.result = MAZE32_GAME_RESULT_BLOCKED;
@@ -459,12 +548,34 @@ 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 (!maze32_maze_has_passage(game->maze, player->position, passage)) {
if (!game_has_passage(game, player->position, passage)) {
processed.breakthrough_hits = record_wall_impact(
player, player->position, command.direction, command.submitted_ms);
if (processed.breakthrough_hits < MAZE32_GAME_BREAKTHROUGH_HITS) {
processed.result = MAZE32_GAME_RESULT_BLOCKED;
return processed;
}
maze32_cell_t destination = moved_cell(player->position, command.direction);
if (!valid_cell(game->maze, destination)) {
reset_perseverance(player);
processed.result = MAZE32_GAME_RESULT_BLOCKED;
return processed;
}
game->opened_passages[cell_index(game->maze, player->position)] |= passage;
game->opened_passages[cell_index(game->maze, destination)] |= opposite_passage(passage);
processed.breakthrough_opened = 1U;
reset_perseverance(player);
}
if (game_has_passage(game, player->position, passage)) {
reset_perseverance(player);
} else {
processed.result = MAZE32_GAME_RESULT_BLOCKED;
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);
@@ -589,7 +700,22 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_
if (reveal_all || is_visible(center, cell, radius)) {
uint16_t index = cell_index(game->maze, cell);
view->cells[view->cell_count].cell_index = index;
view->cells[view->cell_count].passages = game->maze->passages[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++;
}
}
@@ -781,8 +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]", index == first ? "" : ",",
cell->cell_index, cell->passages)) {
if (!append_text(output, output_size, &offset, "%s[%u,%u,%u,%u,%u]",
index == first ? "" : ",", cell->cell_index, cell->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;
+24
View File
@@ -167,6 +167,30 @@ void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id) {
}
}
int maze32_lobby_leave(maze32_lobby_t* lobby, int connection_id, maze32_role_t* role,
unsigned int* player_slot) {
if (lobby == NULL) {
return 0;
}
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
maze32_session_t* session = &lobby->sessions[index];
if (session->role != MAZE32_ROLE_NONE && session->connected &&
session->connection_id == connection_id) {
if (role != NULL) {
*role = session->role;
}
if (player_slot != NULL) {
*player_slot = session->player_slot;
}
memset(session, 0, sizeof(*session));
session->connection_id = -1;
lobby->revision++;
return 1;
}
}
return 0;
}
maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id,
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;
}
+67 -4
View File
@@ -722,6 +722,20 @@ static void send_game_error(maze32_gateway_t* gateway, int socket_fd, const char
send_error(gateway, socket_fd, request_id, maze32_game_result_name(result), message);
}
static void send_breakthrough_impact(maze32_gateway_t* gateway, int socket_fd,
const char* request_id, uint8_t hits) {
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
int written = snprintf(response, sizeof(response),
"{\"v\":1,\"type\":\"error\",\"seq\":%" PRIu32
",\"requestId\":\"%s\",\"payload\":{\"code\":\"blocked\","
"\"message\":\"Там стена. Очень убедительная\","
"\"breakthroughHits\":%u}}",
next_server_sequence(gateway), request_id, (unsigned int)hits);
if (written >= 0 && (size_t)written < sizeof(response)) {
enqueue(gateway, socket_fd, response);
}
}
static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_t* session,
const maze32_client_message_t* message) {
if (session->role == MAZE32_ROLE_OBSERVER) {
@@ -753,7 +767,12 @@ static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_
if (finished_now) {
complete_match(gateway);
}
send_game_error(gateway, session->connection_id, message->request_id, result);
if (result == MAZE32_GAME_RESULT_BLOCKED && processed.breakthrough_hits > 0U) {
send_breakthrough_impact(gateway, session->connection_id, message->request_id,
processed.breakthrough_hits);
} else {
send_game_error(gateway, session->connection_id, message->request_id, result);
}
return;
}
char acknowledgement[160] = "";
@@ -929,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);
@@ -958,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);
}
}
}
+11 -1
View File
@@ -20,6 +20,7 @@ static const maze32_static_asset_t PLAYER_STYLE_ASSET = {"/littlefs/player.css.g
static const maze32_static_asset_t PLAYER_SCRIPT_ASSET = {"/littlefs/player.js.gz",
"text/javascript; charset=utf-8"};
static const maze32_static_asset_t ICON_ASSET = {"/littlefs/icons.svg.gz", "image/svg+xml"};
static const maze32_static_asset_t HEDGE_ASSET = {"/littlefs/hedges.svg.gz", "image/svg+xml"};
static void preserve_static_context(void* context) {
(void)context;
@@ -104,7 +105,7 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
}
httpd_config_t configuration = HTTPD_DEFAULT_CONFIG();
configuration.max_uri_handlers = 13;
configuration.max_uri_handlers = 14;
configuration.max_open_sockets = 9;
configuration.lru_purge_enable = false;
configuration.recv_wait_timeout = 5;
@@ -148,6 +149,12 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
.handler = send_static_asset,
.user_ctx = (void*)&ICON_ASSET,
};
const httpd_uri_t hedges = {
.uri = "/hedges.svg",
.method = HTTP_GET,
.handler = send_static_asset,
.user_ctx = (void*)&HEDGE_ASSET,
};
const httpd_uri_t health_uri = {
.uri = "/health",
.method = HTTP_GET,
@@ -174,6 +181,9 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
if (result == ESP_OK) {
result = httpd_register_uri_handler(*server, &icons);
}
if (result == ESP_OK) {
result = httpd_register_uri_handler(*server, &hedges);
}
if (result == ESP_OK) {
result = httpd_register_uri_handler(*server, &health_uri);
}
Binary file not shown.
+38
View File
@@ -0,0 +1,38 @@
# Hedge sample library manifest
`hedge-sample-library.svg` is a review-only, hand-editable SVG source. It is
not a web, firmware, or generated LittleFS asset.
## Symbols
- Intact straight variants: `hedge-intact-1` through `hedge-intact-6`.
- Topology examples: `hedge-base`, `hedge-corner`, `hedge-crossing`, and
`hedge-end`.
- Perseverance frames: `hedge-impact-1` through `hedge-impact-4` and
`hedge-breakthrough`.
- Permanent openings: `hedge-breach-1` through `hedge-breach-3`.
- Reusable primitives: `leaf-cluster-a`, `leaf-cluster-b`, and `twig-fork`.
All edge art is authored left-to-right. Later integration may rotate it by 90,
180, or 270 degrees and mirror it only when the shared-edge centre remains
aligned. Highlights are distributed rather than directional, so rotation does
not create a lighting contradiction.
## Accessibility and review
The dark outline, branch forks, irregular leaf silhouette, and clear central
gap distinguish the symbols without relying only on hue. The comparison sheet
contains neutral and dark-cell previews at 34, 40, and 46 CSS-pixel targets.
Impact frames are static review frames; no animation, particle system, or
runtime event is implied.
## Size and dependency record
| Source | Raw bytes | gzip -9 bytes | Dependencies |
| --- | ---: | ---: | --- |
| `hedge-sample-library.svg` | 6,495 | 1,884 | None; inline SVG only |
The validator rejects scripts, raster payloads, external URLs, editor metadata,
and missing stable identifiers. Reused `<use>` primitives avoid duplicating
leaf and twig paths. The current sheet is deliberately outside the 1.5 MiB
runtime budget until a future approved integration milestone selects a family.
+5
View File
@@ -14,6 +14,7 @@ browser timestamp from an ESP timestamp.
| command to painted view | browser | `move` send to next frame after matching view render |
| 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
+73 -3
View File
@@ -12,6 +12,20 @@ locally authored `icons.svg` sprite. The deterministic asset build compresses
each file with gzip for LittleFS. There are no remote fonts, frameworks,
analytics, icon services, or runtime network dependencies beyond the local ESP.
## Gameplay icon mapping
`icons.svg` contains editable, local SVG symbols with a shared `64 x 64` view
box for the reference-derived gameplay set: `player`, `key`, `exit-closed`,
`crystal`, `exit-open`, and `spikes`. Player 1 is yellow and Player 2 is blue;
their matching keys and exits use the same owner color. The player, key, and
exit silhouettes remain distinct, and Player 2 uses a dashed secondary
treatment so ownership does not depend on color alone.
The closed and opened arched exits show the existing locked and opened exit
states. The crystal represents the existing vision artifact. The spike symbol
is kept as a local catalog asset but is not shown for blocked movement; it does
not add a trap, damage, protocol, or server-state feature.
## Input contract
Arrow keys and WASD map to `north`, `east`, `south`, and `west` move intentions.
@@ -20,7 +34,11 @@ layouts hide the direction pad and retain swipes; tablet and desktop layouts
show the direction pad and also accept swipes. The persistent `OK` control
sends `drop_key` and exposes the accessible label `Сбросить ключ`.
The centered player avatar is also the touch movement handle. Its transparent
On phones, an always-visible four-button directional pad is the primary touch
control. Its inactive centre prevents diagonal input; each button sends one of
the same four authoritative movement intentions and a held button repeats no
faster than every 170 ms. The centered player avatar is also an optional touch
movement handle. Its transparent
target is 96 by 96 CSS pixels while the visible avatar can deflect at most 28
CSS pixels. A 12-pixel dead zone and diagonal hysteresis resolve only cardinal
intentions. Drag displacement selects a monotonically bounded local cadence
@@ -30,8 +48,60 @@ drag changes into one latest intention; release discards that unsent intention.
The deflection is local input feedback only. The maze redraws only after a
server-authoritative projection and the browser never predicts a cell change.
For every projection, the browser translates only the received cells so the
authoritative `self` cell stays under the centered avatar; this also keeps the
avatar off a wall when the visible projection reaches a maze boundary.
authoritative `self` cell stays under the responsive avatar focal point. On a
portrait phone this is horizontally centered at 30% of the map height; compact
landscape uses a stable left-biased unobstructed point away from the directional
pad. This also keeps the avatar off a wall when the visible projection reaches
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. 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
lower-priority content, and dismiss after 2.6 seconds.
Commands use the version-one protocol envelope and contain intention data only:
+11 -2
View File
@@ -39,6 +39,10 @@ is only echoed for correlation; it grants no authority.
- `restart`: `{}`. Only connected Player 1 may reset a finished match, and both
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
+113 -15
View File
@@ -30,10 +30,11 @@ roles must remain stable:
- black or near-black for the application and map background;
- purple for panels and elevated surfaces;
- turquoise for interactive accents;
- blue for Player 1 and pink for Player 2 across every screen, map object, and
message;
- green for success, yellow for warnings, red for errors, blue for pause, and
orange for disconnection;
- yellow for Player 1 and blue for Player 2 across every screen, map object,
and message; each player's matching key and exit use the same ownership
color;
- green for success, amber for warnings, red for errors, periwinkle for pause,
and orange for disconnection;
- green vegetation, bushes, and plants for maze walls.
When two semantic roles use related colors, icons, labels, outlines, or shapes
@@ -41,6 +42,12 @@ must keep them unambiguous. Important player and object information must not be
communicated by color alone. Key objects use an additional label, outline, or
marker.
The gameplay icon family is locally authored SVG artwork derived from the
approved running-player, key, arched-exit, crystal, opened-exit, and spike
reference silhouettes. The spike icon remains a catalog asset only; blocked
movement uses the maze feedback defined below and never represents a trap or a
new gameplay mechanic.
WCAG AA contrast certification and dedicated color-vision-deficiency palettes
are not MVP requirements.
@@ -64,7 +71,10 @@ are not MVP requirements.
- Visible keyboard-focus outlines are not required for the MVP.
- On computers, movement works from the keyboard and from an on-screen
directional control cluster fixed in the lower-right corner.
- On touch devices, the centered player avatar is the movement handle. Its
- On touch devices, an always-visible four-button directional pad in the lower
right is the primary movement control. Its centre is inactive, each button
is at least 48 by 48 CSS pixels, and it sends only one cardinal intention.
The centered player avatar remains an optional movement handle. Its
transparent touch target is at least 96 by 96 CSS pixels and its visible
character remains unobscured. During a drag, it deflects no more than 28 CSS
pixels from center and returns exactly to center on release, cancellation,
@@ -72,8 +82,9 @@ are not MVP requirements.
- The avatar drag has a 12 CSS-pixel dead zone and directional hysteresis. Its
bounded repeat cadence changes monotonically from 360 ms at a small
intentional displacement to 170 ms at the target edge; it never exceeds the
server's 150 ms movement cooldown. Touch swipes remain supported. Phones do
not show a directional-button cluster.
server's 150 ms movement cooldown. The pad's hold repeat is 170 ms. Touch
swipes remain supported. The pad, avatar drag, swipe, and keyboard inputs
share one bounded latest-intent path.
- The always-visible key-drop control is labelled `OK`, includes a key icon,
and is positioned closer to the upper edge of the screen than the
directional controls. Dropping does not require confirmation.
@@ -89,19 +100,40 @@ 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.
- Errors and rejected actions appear as contextual tooltips or notices near
the relevant control or status area.
- 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
priority and persists until recovery; all other messages replace lower
priority content, deduplicate by event, and dismiss after 2.6 seconds.
- The lobby uses separate blocks for players, observers, and match actions.
- Network provisioning uses the same colors, type, controls, and humor as the
game application.
## 7. Player map presentation
- Keep the player near the center of the map viewport except where maze
boundaries prevent it.
- Keep the portrait player horizontally centered at approximately 30% of the
usable map height, above the directional-pad hand zone. In compact landscape,
use the stable left-biased focal point in the largest unobstructed map area.
Recalculate it after resize and rotation without moving the authoritative
player away from its received `self` cell.
- Render unknown space as a solid background. Never infer, retain, animate, or
decorate maze geometry outside the server-provided visible projection.
- Compute cell size from the current server projection and available map area.
@@ -111,8 +143,74 @@ are not MVP requirements.
- Player zoom and pan controls are not part of the MVP.
- Use different colors plus labels, outlines, shapes, or icons to distinguish
player ownership and object types.
- Give immediate visual feedback when movement is rejected by a wall without
revealing hidden state.
- 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.
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;
it never shows travel or moving accents.
### Hedge art review contract
- Hedge samples are direct top-down vectors for review only; they are not
runtime assets in this milestone. A segment occupies one cell edge and uses
a centered, 10 CSS-pixel-wide foliage band at 40 CSS pixels per cell, scaling
proportionally at 34 and 46 pixels.
- The family uses deep shadow `#173d2a`, branch `#765033`, foliage dark
`#296443`, foliage mid `#4f9b55`, foliage light `#8cc968`, and pale leaf
highlight `#c7e68a`. A dark outline and branch fork preserve silhouette in
grayscale; color is supplementary depth, not the only boundary cue.
- Each intact symbol combines three or more named leaf clusters with visible
twig forks, a restrained shadow, and an irregular outer silhouette. Straight
symbols tile end-to-end without double borders; corner, crossing, and end
symbols share their edge centre and may rotate or mirror without directional
lighting.
- 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 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.
### Runtime hedge rendering
- The local `/hedges.svg` sprite is derived from the approved review source.
It contains only reusable runtime symbols and no scripts, raster data, or
external dependencies. The review sheet in `docs/` is never requested by a
browser page.
- A map uses one non-interactive hedge-edge element for each canonical closed
or server-opened edge in its current projection. Closed shared edges are
owned by their north or west cell; east and south edges are rendered only at
the current projection boundary. This bounds a full 32 by 32 map to 2,112
hedge elements and avoids duplicated foliage.
- A stable integer hash of the canonical edge index and orientation selects one
of the six approved intact variants. Opened edges select one of the three
approved breach variants. The same projection edge always receives the same
art without random state, retained exploration, or a protocol field.
- Gameplay readability takes priority over foliage density. Hedge foliage is
at most 20% of one cell, and the center line of every horizontal or vertical
symbol must coincide with its authoritative cell edge. Rotation must preserve
that center line instead of shifting a vertical hedge through cell centers.
Every runtime SVG instance uses the approved `160 x 40` view box scaled to
exactly one cell edge; source-coordinate artwork must never overflow into
unrelated cells. Cell interiors remain visually quiet and continuous so
passages read as open floor rather than as a grid of highlighted rectangles.
- Hedge edges are decorative, have `pointer-events: none`, sit below map
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. 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
+48
View File
@@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 760" role="img" aria-labelledby="title desc">
<title id="title">Top-down living hedge sample library</title>
<desc id="desc">Review-only editable vector symbols for intact hedges, joins, impact stages, and breached passages.</desc>
<defs>
<g id="leaf-cluster-a" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round">
<path fill="#296443" d="M2 13C0 6 7 0 13 3c2-7 12-5 14 2 8-1 11 8 5 13 3 8-6 13-12 8-6 5-14 1-13-6-7 0-9-5-5-7Z"/>
<path fill="#4f9b55" d="M7 13c2-5 7-8 12-7 5 0 8 4 9 8-3 5-8 8-14 7-5 0-8-3-7-8Z"/>
<path fill="#c7e68a" d="M12 9c3-3 8-3 10 0-2 3-7 4-10 0Z"/>
</g>
<g id="leaf-cluster-b" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round">
<path fill="#296443" d="M1 16C-1 8 5 2 11 4c1-7 11-7 14-1 7-3 13 5 8 11 5 6-2 14-9 11-4 7-15 4-15-3-8 2-12-4-8-6Z"/>
<path fill="#8cc968" d="M8 14c2-5 8-7 13-5 4 2 6 7 4 11-5 4-13 3-17-2Z"/>
<path fill="#4f9b55" d="M4 18c5 1 10 4 15 1-1 5-8 8-13 5Z"/>
</g>
<g id="twig-fork" fill="none" stroke="#765033" stroke-width="2" stroke-linecap="round">
<path d="M3 20 16 8 29 3M16 8l2 12M16 8 7 4"/>
</g>
<g id="hedge-base">
<path d="M0 11C9 2 20 4 30 9c12-8 23-7 34 1 10-5 22-5 32 1 11-7 23-6 34 1 9-5 22-4 30 4v17c-10 5-22 5-32 0-11 7-23 6-34 0-10 6-22 6-32 0-11 6-23 6-34 0-10 5-20 4-30-3Z" fill="#173d2a"/>
<use href="#twig-fork" x="5" y="9"/><use href="#twig-fork" x="54" y="8"/><use href="#twig-fork" x="105" y="9"/>
<use href="#leaf-cluster-a" x="1" y="5"/><use href="#leaf-cluster-b" x="27" y="4"/><use href="#leaf-cluster-a" x="54" y="6"/><use href="#leaf-cluster-b" x="80" y="4"/><use href="#leaf-cluster-a" x="107" y="5"/><use href="#leaf-cluster-b" x="132" y="5"/>
</g>
<g id="hedge-intact-1"><use href="#hedge-base"/></g>
<g id="hedge-intact-2"><use href="#hedge-base"/><path fill="#8cc968" d="M20 7l8-3 4 4-8 4Zm61 3 8-4 4 4-7 4Zm48 12 7-3 3 5-7 3Z"/></g>
<g id="hedge-intact-3"><use href="#hedge-base"/><path fill="none" stroke="#765033" stroke-width="2" d="M38 27 49 9m36 18L73 7m47 20 13-17"/></g>
<g id="hedge-intact-4"><use href="#hedge-base"/><path fill="#4f9b55" d="M6 27c12-5 20 6 31 0 11-6 20 6 32 0 11-6 21 5 33-1 10-5 19 3 29 0v8H6Z"/></g>
<g id="hedge-intact-5"><use href="#hedge-base"/><path fill="#c7e68a" d="m10 17 5-4 4 3-5 4Zm42-8 5-4 4 3-5 4Zm46 11 5-4 4 3-5 4Zm38-8 5-4 4 3-5 4Z"/></g>
<g id="hedge-intact-6"><use href="#hedge-base"/><path fill="none" stroke="#173d2a" stroke-width="2" d="M19 30 32 14l14 13m33 3 12-15 13 14m17 1 11-12 12 10"/></g>
<g id="hedge-corner"><use href="#hedge-base" transform="translate(0 80) rotate(-90)"/><use href="#hedge-base"/></g>
<g id="hedge-crossing"><use href="#hedge-base"/><use href="#hedge-base" transform="translate(80 -40) rotate(90)"/></g>
<g id="hedge-end"><use href="#hedge-base"/><path fill="#173d2a" stroke="#765033" stroke-width="2" d="M150 7c18 1 19 29 0 31-11-2-11-28 0-31Z"/></g>
<g id="hedge-impact-1"><use href="#hedge-intact-1"/><path fill="#8cc968" d="m70 2 8 3-3 6-8-3Zm12 28 7 2-3 5-7-2Z"/></g>
<g id="hedge-impact-2"><use href="#hedge-intact-1"/><path fill="#173d2a" d="M64 0h30v40H64Z" opacity=".18"/><path fill="#8cc968" d="m66 1 8 3-3 6-8-3Zm15 7 8 3-3 6-8-3Zm4 20 7 2-3 5-7-2Z"/></g>
<g id="hedge-impact-3"><use href="#hedge-intact-1" transform="skewX(-4)"/><path fill="#173d2a" d="M58 0h42v40H58Z" opacity=".25"/><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="#173d2a" d="M53 0h54v40H53Z" opacity=".32"/><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-3Zm4 20 7 2-3 5-7-2Z"/></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"/><path fill="#8cc968" d="m52 5 8 4-4 6-8-4Zm55 19 8-4 4 6-8 4Z"/></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"/><path fill="#c7e68a" d="m49 29 7-3 3 5-7 3Zm61 0 7-3 3 5-7 3Z"/></g>
</defs>
<rect width="960" height="760" fill="#e7e3d8"/>
<g fill="#173d2a" font-family="monospace" font-size="16"><text x="32" y="34">INTACT STRAIGHT HEDGE FAMILIES</text><text x="32" y="178">JOINS AND BOUNDARIES</text><text x="32" y="322">PERSEVERANCE PROGRESSION</text><text x="32" y="466">PERMANENT BREACHES</text><text x="32" y="610">DARK CELL SCALE PREVIEWS: 34 / 40 / 46 CSS px</text></g>
<g transform="translate(32 48)"><use href="#hedge-intact-1"/><use href="#hedge-intact-2" x="155"/><use href="#hedge-intact-3" x="310"/><use href="#hedge-intact-4" x="465"/><use href="#hedge-intact-5" x="620"/><use href="#hedge-intact-6" x="775"/></g>
<g transform="translate(32 190)"><use href="#hedge-base"/><use href="#hedge-corner" x="190"/><use href="#hedge-crossing" x="380"/><use href="#hedge-end" x="570"/></g>
<g transform="translate(32 335)"><use href="#hedge-intact-1"/><use href="#hedge-impact-1" x="155"/><use href="#hedge-impact-2" x="310"/><use href="#hedge-impact-3" x="465"/><use href="#hedge-impact-4" x="620"/><use href="#hedge-breakthrough" x="775"/></g>
<g transform="translate(32 480)"><use href="#hedge-breach-1"/><use href="#hedge-breach-2" x="220"/><use href="#hedge-breach-3" x="440"/></g>
<g transform="translate(32 630)"><g transform="scale(.85)"><rect width="170" height="85" fill="#11131a"/><use href="#hedge-intact-2" transform="translate(4 21)"/><use href="#hedge-breach-1" transform="translate(4 51)"/></g><g transform="translate(240 0)"><rect width="200" height="100" fill="#11131a"/><use href="#hedge-intact-3" transform="translate(8 28) scale(1.15)"/><use href="#hedge-breach-2" transform="translate(8 62) scale(1.15)"/></g><g transform="translate(510 0)"><rect width="230" height="115" fill="#11131a"/><use href="#hedge-intact-5" transform="translate(10 33) scale(1.3)"/><use href="#hedge-breach-3" transform="translate(10 73) scale(1.3)"/></g></g>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

+36
View File
@@ -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;
}
+32
View File
@@ -346,6 +346,37 @@ static void test_victory_and_draw_window(void) {
printf("game outward result window: PASS (200 ms draw boundary, 201 ms winner)\n");
}
static void test_persistent_bush_wall_breakthrough(void) {
maze32_maze_t maze;
open_test_grid(&maze);
maze.passages[index_of(2U, 2U)] &= (uint8_t)~MAZE32_PASSAGE_EAST;
maze.passages[index_of(3U, 2U)] &= (uint8_t)~MAZE32_PASSAGE_WEST;
maze32_game_t game = active_game(&maze);
game.players[0].position = (maze32_cell_t){2U, 2U};
for (uint8_t hit = 1U; hit < MAZE32_GAME_BREAKTHROUGH_HITS; ++hit) {
maze32_game_command_result_t result =
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, hit * 500U);
assert(result.result == MAZE32_GAME_RESULT_BLOCKED);
assert(result.breakthrough_hits == hit);
assert(!result.breakthrough_opened);
assert(game.players[0].position.x == 2U);
}
maze32_game_command_result_t result =
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 2500U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.breakthrough_opened);
assert(result.breakthrough_hits == MAZE32_GAME_BREAKTHROUGH_HITS);
assert(game.players[0].position.x == 3U);
assert(game.opened_passages[index_of(2U, 2U)] & MAZE32_PASSAGE_EAST);
assert(game.opened_passages[index_of(3U, 2U)] & MAZE32_PASSAGE_WEST);
game.players[1].position = (maze32_cell_t){3U, 2U};
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_WEST, 2600U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED);
assert(game.players[1].position.x == 2U);
printf("game persistent bush-wall breakthrough: PASS (five impacts, shared passage)\n");
}
int main(void) {
test_inventory_drop_and_sabotage();
test_atomic_meeting_return();
@@ -353,6 +384,7 @@ int main(void) {
test_vision_artifact_duration();
test_compass_and_role_projections();
test_victory_and_draw_window();
test_persistent_bush_wall_breakthrough();
puts("game mechanics tests: PASS");
return 0;
}
+47 -1
View File
@@ -39,8 +39,11 @@ static void test_protocol_schema(void) {
const char* restart = "{\"v\":1,\"type\":\"restart\",\"seq\":5,\"payload\":{}}";
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;
}
+52
View File
@@ -0,0 +1,52 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const zlib = require("node:zlib");
const samplePath = path.join(__dirname, "..", "..", "docs", "hedge-sample-library.svg");
const sample = fs.readFileSync(samplePath, "utf8");
const runtimePath = path.join(__dirname, "..", "..", "web", "hedges.svg");
const runtime = fs.readFileSync(runtimePath, "utf8");
test("hedge review sheet is local editable SVG with complete stable symbols", () => {
assert.match(sample, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
assert.doesNotMatch(sample, /<(?:script|image|foreignObject)\b/i);
assert.doesNotMatch(sample, /(?:href="https?:|url\(|data:|@import|<style\b)/i);
assert.doesNotMatch(sample, /(?:sodipodi:|inkscape:|metadata\b)/i);
for (const identifier of [
"leaf-cluster-a", "leaf-cluster-b", "twig-fork", "hedge-base",
"hedge-intact-1", "hedge-intact-2", "hedge-intact-3", "hedge-intact-4",
"hedge-intact-5", "hedge-intact-6", "hedge-corner", "hedge-crossing",
"hedge-end", "hedge-impact-1", "hedge-impact-2", "hedge-impact-3",
"hedge-impact-4", "hedge-breakthrough", "hedge-breach-1",
"hedge-breach-2", "hedge-breach-3"
]) {
assert.match(sample, new RegExp(`id="${identifier}"`));
}
assert.ok((sample.match(/<use href=/g) || []).length >= 20);
assert.ok(zlib.gzipSync(sample, {level: 9}).length < sample.length);
});
test("runtime hedge sprite remains local and contains approved runtime symbols", () => {
assert.match(runtime, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
assert.doesNotMatch(runtime, /<(?:script|image|foreignObject|metadata)\b/i);
assert.doesNotMatch(runtime, /(?:href="https?:|url\(|data:|@import|<style\b)/i);
for (const identifier of [
"hedge-intact-1", "hedge-intact-2", "hedge-intact-3", "hedge-intact-4",
"hedge-intact-5", "hedge-intact-6", "hedge-impact-1", "hedge-impact-2",
"hedge-impact-3", "hedge-impact-4", "hedge-breakthrough", "hedge-breach-1",
"hedge-breach-2", "hedge-breach-3"
]) {
assert.match(runtime, new RegExp(`id="${identifier}"`));
assert.match(sample, new RegExp(`id="${identifier}"`));
}
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"/);
}
});
+267 -12
View File
@@ -63,6 +63,22 @@ test("latest movement intent coalesces while one command is in flight", () => {
assert.equal(flow.latest, null);
});
test("directional pad repeat remains slower than the authoritative cooldown", () => {
assert.ok(player.DIRECTIONAL_PAD_REPEAT_MS > 150);
const dispatched = [];
const flow = new player.LatestIntentFlow((direction, element) => {
dispatched.push({direction, element});
return "move-1";
});
const button = {id: "north"};
flow.request("north", button);
flow.request("east", {id: "east"});
assert.deepEqual(flow.latest, {direction: "east", element: {id: "east"}});
flow.release();
assert.equal(flow.latest, null);
assert.deepEqual(dispatched, [{direction: "north", element: button}]);
});
test("latency correlation is bounded and only completes matching authoritative views", () => {
let clock = 0;
const tracker = new player.LatencyTracker(() => clock);
@@ -87,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);
@@ -102,6 +141,13 @@ test("authoritative self cell remains below the centered avatar at map edges", (
assert.deepEqual(player.playerMapOffset(-1, 7, 0, 0, 7, 7, 46), {x: 0, y: 0});
});
test("responsive player focal point stays above phone controls and composes with map offsets", () => {
assert.deepEqual(player.playerFocalPoint(400, 800), {x: 200, y: 240});
assert.deepEqual(player.playerFocalPoint(900, 400), {x: 378, y: 180});
assert.deepEqual(player.playerFocalMapOffset(24, 7, 1, 1, 5, 5, 46, 400, 800), {x: 0, y: -160});
assert.deepEqual(player.playerFocalMapOffset(0, 7, 0, 0, 7, 7, 46, 400, 800), {x: 138, y: -22});
});
test("onboarding has bounded, replayable steps", () => {
assert.equal(player.ONBOARDING_STEPS.length, 8);
assert.equal(player.nextOnboardingStep(0), 1);
@@ -109,25 +155,112 @@ 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");
assert.match(script, /addEventListener\("pointerup", stopAvatarControl\)/);
assert.match(script, /addEventListener\("pointercancel", stopAvatarControl\)/);
assert.match(script, /addEventListener\("lostpointercapture", stopAvatarControl\)/);
assert.match(script, /addEventListener\("blur", resetAvatarControl\)/);
assert.match(script, /connectionState !== "online"\) resetAvatarControl\(\)/);
assert.match(script, /snapshot\.phase === "paused"\) \{ resetAvatarControl\(\)/);
assert.match(script, /addEventListener\("blur", resetPlayerControls\)/);
assert.match(script, /connectionState === "offline"\) \{ resetPlayerControls\(\);/);
assert.match(script, /snapshot\.phase === "paused"\) \{ resetPlayerControls\(\)/);
assert.match(script, /state\.avatar\.startX = event\.clientX/);
assert.match(script, /event\.clientX - state\.avatar\.startX/);
assert.match(script, /windowObject\.addEventListener\("resize", refreshPlayerFocalPoint\)/);
assert.match(script, /windowObject\.addEventListener\("orientationchange", refreshPlayerFocalPoint\)/);
assert.match(script, /setMuted\(!state\.muted\)/);
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>/);
});
test("playful feedback uses bounded maze-only effects and a top message rail", () => {
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, /<main id="app">\s*<div class="message-rail" id="notice" role="status" aria-live="polite" aria-atomic="true" hidden><\/div>\s*<header/);
assert.doesNotMatch(html, /wall-bump-text/);
assert.match(css, /\.maze\.impact-north/);
assert.match(css, /\.maze\.rate-limited \{ filter: brightness\(\.48\) blur\(2px\)/);
assert.match(css, /\[data-reduced-motion="true"\] \.maze\[class\*="impact-"\] \{ animation: none;/);
assert.match(css, /\[data-reduced-motion="true"\] \.maze\.rate-limited \{ transition: none;/);
assert.match(css, /\.message-rail \{ position: fixed; top:/);
assert.match(script, /moveDirections: new Map\(\)/);
assert.match(script, /if \(message\.payload\.code === "blocked"\) \{ showImpact\(direction, message\.payload\.breakthroughHits\); feedback\(false\); return; \}/);
assert.match(css, /\.cell\.bush-impact/);
assert.match(css, /\.hedge-layer/);
assert.match(css, /\.hedge-edge \{[^}]*height: calc\(var\(--cell\) \* \.2\)/);
assert.match(css, /\.hedge-edge\.hedge-vertical \{[^}]*left: calc\(var\(--hedge-x\) \* var\(--cell\) - var\(--cell\) \* \.5\);[^}]*top: calc\(var\(--hedge-y\) \* var\(--cell\) \+ var\(--cell\) \* \.4\)/);
assert.match(css, /\.observer-maze \.hedge-edge\.hedge-vertical \{[^}]*top: calc\(var\(--hedge-y\) \* var\(--cell\) \+ var\(--cell\) \* \.5 - 1px\)/);
assert.match(css, /\.cell::after \{[^}]*background: #1c1b27;[^}]*opacity: \.55/);
assert.match(script, /projectedHedgeEdges/);
assert.match(script, /svg\.setAttribute\("viewBox", "0 0 160 40"\)/);
assert.match(script, /svg\.setAttribute\("preserveAspectRatio", "none"\)/);
assert.match(script, /\/hedges\.svg#/);
assert.match(script, /if \(message\.payload\.code === "rate_limited"\) \{ showRateLimit\(\); feedback\(false\); return; \}/);
assert.doesNotMatch(script, /Там стена\. Очень убедительная\.|Слишком быстро! Сервер просит немного меньше турбо\./);
});
test("mobile directional pad is explicit, bounded, and resettable", () => {
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");
for (const label of ["Двигаться вверх", "Двигаться вниз", "Двигаться влево", "Двигаться вправо"]) {
assert.match(html, new RegExp(`aria-label="${label}"`));
}
assert.match(html, /class="dpad-center" aria-hidden="true"/);
assert.match(html, /data-direction="north"[^>]*disabled/);
assert.match(css, /\.dpad \{[^}]*grid-template-rows: repeat\(3, 58px\)/);
assert.match(css, /\.dpad button:disabled/);
assert.match(css, /\.dpad button\.active/);
assert.match(css, /\.dpad button\.pending/);
assert.match(css, /\.dpad \{[^}]*touch-action: none/);
assert.match(css, /\.dpad button \{[^}]*touch-action: none/);
assert.match(css, /grid-template-columns: repeat\(3, 62px\)/);
assert.match(script, /function resetDirectionalPad\(\)/);
assert.match(script, /addEventListener\("pointercancel", stopDirectionalPad\)/);
assert.match(script, /addEventListener\("lostpointercapture", stopDirectionalPad\)/);
assert.match(script, /windowObject\.addEventListener\("blur", resetPlayerControls\)/);
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());
@@ -178,29 +311,59 @@ test("command envelopes contain intentions rather than client-owned state", () =
test("chunk assembly replaces projection cells without explored-map memory", () => {
const assembler = new player.SnapshotAssembler();
const second = {v: 1, type: "view", seq: 20, payload: {role: "player1", width: 8, height: 8, chunk: 1, chunks: 2, cells: [[10, 15]], objects: []}};
const first = {v: 1, type: "view", seq: 20, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 9, inventory: null, visionMoves: 0, hint: "none", chunk: 0, chunks: 2, cells: [[9, 15]], objects: [["key", 0, 0, 10]]}};
const second = {v: 1, type: "view", seq: 20, payload: {role: "player1", width: 8, height: 8, chunk: 1, chunks: 2, cells: [[10, 15, 0]], objects: []}};
const first = {v: 1, type: "view", seq: 20, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 9, inventory: null, visionMoves: 0, hint: "none", chunk: 0, chunks: 2, cells: [[9, 15, 2]], objects: [["key", 0, 0, 10]]}};
assert.equal(assembler.accept(second), null);
const complete = assembler.accept(first);
assert.deepEqual(player.projectedCellIndices(complete), [9, 10]);
const replacement = assembler.accept({v: 1, type: "view", seq: 21, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 18, inventory: 0, visionMoves: 9, hint: "ne", chunk: 0, chunks: 1, cells: [[18, 15]], objects: []}});
const replacement = assembler.accept({v: 1, type: "view", seq: 21, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 18, inventory: 0, visionMoves: 9, hint: "ne", chunk: 0, chunks: 1, cells: [[18, 15, 0]], objects: []}});
assert.deepEqual(player.projectedCellIndices(replacement), [18]);
assert.equal(replacement.cells.some(cell => cell[0] === 9 || cell[0] === 10), false);
assert.equal(assembler.accept({v: 1, type: "view", seq: 22, payload: {chunk: 0, chunks: player.MAX_VIEW_CHUNKS + 1, cells: [], objects: []}}), null);
assert.equal(player.snapshotIsValid({width: 32, height: 32, cells: [[1023, 15]], objects: []}), true);
assert.equal(player.snapshotIsValid({width: 32, height: 32, cells: [[1023, 15, 0]], objects: []}), true);
assert.equal(player.snapshotIsValid({width: 33, height: 32, cells: [], objects: []}), false);
assert.equal(player.snapshotIsValid({width: 32, height: 33, cells: [], objects: []}), false);
});
test("hedge edges are canonical, bounded, and deterministic", () => {
const cells = [[0, 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]];
const first = player.projectedHedgeEdges(cells, 2);
const second = player.projectedHedgeEdges(cells, 2);
assert.deepEqual(first, second);
assert.equal(new Set(first.map(edge => edge.key)).size, first.length);
assert.ok(first.length <= cells.length * 2 + 2 * 2);
assert.equal(player.hedgeVariant(first[0], 6), player.hedgeVariant(first[0], 6));
const opened = player.projectedHedgeEdges([[0, 0, 2], [1, 0, 8]], 2);
assert.equal(opened.filter(edge => edge.opened).length, 1);
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", () => {
@@ -217,12 +380,13 @@ test("player assets are local, Russian, accessible, and responsive", () => {
assert.match(css, /@media \(max-width: 720px\)/);
assert.match(css, /@media \(min-width: 721px\) and \(max-width: 980px\)/);
assert.match(css, /height: calc\(100dvh - 78px\)/);
assert.match(css, /\.dpad \{ display: none; \}/);
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/);
assert.match(script, /resetAvatarControl/);
assert.match(script, /playerCellSize/);
assert.match(css, /\(hover: none\) and \(pointer: coarse\) and \(max-height: 720px\)/);
@@ -230,6 +394,66 @@ 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");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const playerUi = fs.readFileSync(path.join(project, "docs/PLAYER_UI.md"), "utf8");
for (const iconId of ["player", "key", "exit-closed", "crystal", "exit-open", "spikes"]) {
assert.match(icons, new RegExp(`<symbol id="${iconId}" viewBox="0 0 64 64">`));
}
assert.doesNotMatch(icons, /<(?:image|foreignObject)\b/i);
assert.doesNotMatch(icons, /data:image\//i);
assert.match(script, /icon\(variant \? "exit-open" : "exit-closed", "object object-exit"\)/);
assert.match(script, /icon\("crystal", "object object-vision"\)/);
assert.match(script, /icon\("player", `pawn/);
assert.match(html, /\/icons\.svg#spikes/);
assert.match(css, /--p1: #ffd33d/);
assert.match(css, /--p2: #41b6ff/);
assert.match(css, /--warning: #ffad38/);
assert.match(css, /--pause: #9b8cff/);
assert.match(css, /\.pawn-owner-two \{ border-style: dashed; \}/);
assert.match(html, /Жёлтый/);
assert.match(html, /Синий/);
assert.match(playerUi, /`player`, `key`, `exit-closed`,\n`crystal`, `exit-open`, and `spikes`/);
});
test("observer view fits large screens and bounds zoom", () => {
assert.equal(player.observerFitScale(1024, 600, 32, 32), 2.25);
assert.equal(player.clampObserverZoom(.01), .25);
@@ -253,7 +477,7 @@ test("observer protocol exposes no mutation control path", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const observerSection = html.match(/<section class="screen observer-screen"[\s\S]*?<\/section>/)[0];
assert.doesNotMatch(observerSection, /data-direction|drop-key|start-match/);
const observer = {v: 1, type: "view", seq: 30, payload: {role: "observer", phase: "active", result: "none", width: 2, height: 2, players: [{cell: 0, key: null, exited: 0, connected: 1}, {cell: 3, key: 1, exited: 0, connected: 1}], chunk: 0, chunks: 1, cells: [[0, 6], [1, 12], [2, 3], [3, 9]], objects: []}};
const observer = {v: 1, type: "view", seq: 30, payload: {role: "observer", phase: "active", result: "none", width: 2, height: 2, players: [{cell: 0, key: null, exited: 0, connected: 1}, {cell: 3, key: 1, exited: 0, connected: 1}], chunk: 0, chunks: 1, cells: [[0, 6, 0], [1, 12, 0], [2, 3, 0], [3, 9, 0]], objects: []}};
const snapshot = new player.SnapshotAssembler().accept(observer);
assert.equal(snapshot.role, "observer");
assert.deepEqual(snapshot.players.map(item => item.cell), [0, 3]);
@@ -266,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/);
});
+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 40" aria-hidden="true">
<defs>
<g id="leaf-cluster-a" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round"><path fill="#296443" d="M2 13C0 6 7 0 13 3c2-7 12-5 14 2 8-1 11 8 5 13 3 8-6 13-12 8-6 5-14 1-13-6-7 0-9-5-5-7Z"/><path fill="#4f9b55" d="M7 13c2-5 7-8 12-7 5 0 8 4 9 8-3 5-8 8-14 7-5 0-8-3-7-8Z"/><path fill="#c7e68a" d="M12 9c3-3 8-3 10 0-2 3-7 4-10 0Z"/></g>
<g id="leaf-cluster-b" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round"><path fill="#296443" d="M1 16C-1 8 5 2 11 4c1-7 11-7 14-1 7-3 13 5 8 11 5 6-2 14-9 11-4 7-15 4-15-3-8 2-12-4-8-6Z"/><path fill="#8cc968" d="M8 14c2-5 8-7 13-5 4 2 6 7 4 11-5 4-13 3-17-2Z"/></g>
<g id="twig-fork" fill="none" stroke="#765033" stroke-width="2" stroke-linecap="round"><path d="M3 20 16 8 29 3M16 8l2 12M16 8 7 4"/></g>
<g id="hedge-base"><path d="M0 11C9 2 20 4 30 9c12-8 23-7 34 1 10-5 22-5 32 1 11-7 23-6 34 1 9-5 22-4 30 4v17c-10 5-22 5-32 0-11 7-23 6-34 0-10 6-22 6-32 0-11 6-23 6-34 0-10 5-20 4-30-3Z" fill="#173d2a"/><use href="#twig-fork" x="5" y="9"/><use href="#twig-fork" x="54" y="8"/><use href="#twig-fork" x="105" y="9"/><use href="#leaf-cluster-a" x="1" y="5"/><use href="#leaf-cluster-b" x="27" y="4"/><use href="#leaf-cluster-a" x="54" y="6"/><use href="#leaf-cluster-b" x="80" y="4"/><use href="#leaf-cluster-a" x="107" y="5"/><use href="#leaf-cluster-b" x="132" y="5"/></g>
<g id="hedge-intact-1"><use href="#hedge-base"/></g>
<g id="hedge-intact-2"><use href="#hedge-base"/><path fill="#8cc968" d="M20 7l8-3 4 4-8 4Zm61 3 8-4 4 4-7 4Zm48 12 7-3 3 5-7 3Z"/></g>
<g id="hedge-intact-3"><use href="#hedge-base"/><path fill="none" stroke="#765033" stroke-width="2" d="M38 27 49 9m36 18L73 7m47 20 13-17"/></g>
<g id="hedge-intact-4"><use href="#hedge-base"/><path fill="#4f9b55" d="M6 27c12-5 20 6 31 0 11-6 20 6 32 0 11-6 21 5 33-1 10-5 19 3 29 0v8H6Z"/></g>
<g id="hedge-intact-5"><use href="#hedge-base"/><path fill="#c7e68a" d="m10 17 5-4 4 3-5 4Zm42-8 5-4 4 3-5 4Zm46 11 5-4 4 3-5 4Zm38-8 5-4 4 3-5 4Z"/></g>
<g id="hedge-intact-6"><use href="#hedge-base"/><path fill="none" stroke="#173d2a" stroke-width="2" d="M19 30 32 14l14 13m33 3 12-15 13 14m17 1 11-12 12 10"/></g>
<g id="hedge-impact-1"><use href="#hedge-intact-1"/><path fill="#8cc968" d="m70 2 8 3-3 6-8-3Zm12 28 7 2-3 5-7-2Z"/></g>
<g id="hedge-impact-2"><use href="#hedge-intact-1"/><path fill="#173d2a" d="M64 0h30v40H64Z" opacity=".18"/></g>
<g id="hedge-impact-3"><use href="#hedge-intact-1" transform="skewX(-4)"/><path fill="none" stroke="#765033" stroke-width="3" d="m71 28 11-18m1 16 11-13"/></g>
<g id="hedge-impact-4"><use href="#hedge-intact-1" transform="translate(0 4) scale(1 .86)"/><path fill="#c7e68a" d="m61 2 8 3-3 6-8-3Zm13 7 8 3-3 6-8-3Zm14-4 8 3-3 6-8-3Z"/></g>
<g id="hedge-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-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>

After

Width:  |  Height:  |  Size: 4.1 KiB

+8 -2
View File
@@ -1,15 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="key" viewBox="0 0 24 24"><circle cx="8" cy="9" r="4" fill="none" stroke="currentColor" stroke-width="3"/><path d="M11 12l9 9m-4-4 2-2m-5-1 2-2" fill="none" stroke="currentColor" stroke-width="3"/></symbol>
<!-- Reference-derived gameplay symbols use a shared 64 by 64 view box and currentColor. -->
<symbol id="player" viewBox="0 0 64 64"><path fill="currentColor" d="M37 5a9 9 0 1 1 0 18 9 9 0 0 1 0-18Zm-8 20 10-2 10 7 8-5a6 6 0 1 1 6 10l-12 8a6 6 0 0 1-7 0l-7-5-3 11 12 8a6 6 0 0 1-7 10L24 57a6 6 0 0 1-2-7l4-15-7 4-4 10a6 6 0 1 1-11-4l5-14a6 6 0 0 1 3-3l13-7a6 6 0 0 1 4 4Z"/></symbol>
<symbol id="key" viewBox="0 0 64 64"><path fill="currentColor" fill-rule="evenodd" d="M43 5a17 17 0 0 0-14 27L5 56l3 3 5-2 3 4 5-2 3 4 6-6-4-4 7-7A17 17 0 1 0 43 5Zm0 11a6 6 0 1 1 0 12 6 6 0 0 1 0-12Z" clip-rule="evenodd"/></symbol>
<symbol id="exit-closed" viewBox="0 0 64 64"><path fill="currentColor" d="M15 59V29C15 10 49 10 49 29v30H15Zm10-30v30h14V29c0-8-14-8-14 0Zm-17-8 6 2-2 10-7-2 3-10Zm42 2 6-2 3 10-7 2-2-10ZM7 38h7v11H7V38Zm43 0h7v11h-7V38ZM14 9l7-5 4 9-7 3-4-7Zm32-5 7 5-4 7-7-3 4-9Z"/></symbol>
<symbol id="exit-open" viewBox="0 0 64 64"><path fill="currentColor" d="M15 59V29C15 10 49 10 49 29v30H15Zm10-30v30h14V29c0-8-14-8-14 0ZM7 38h7v11H7V38Zm43 0h7v11h-7V38ZM14 9l7-5 4 9-7 3-4-7Zm32-5 7 5-4 7-7-3 4-9Z"/><path fill="currentColor" d="M25 36h10v-7l14 12-14 12v-7H25V36Z"/></symbol>
<symbol id="crystal" viewBox="0 0 64 64"><path fill="currentColor" fill-rule="evenodd" d="m32 5 19 20-19 34L13 25 32 5Zm0 12-8 9h16l-8-9Zm-11 17 11 17 11-17H21Z" clip-rule="evenodd"/><path fill="currentColor" d="M32 1h4v8h-4V1ZM5 29h9v5H5v-5Zm45 0h9v5h-9v-5ZM10 11l6-6 4 4-6 6-4-4Zm34-2 4-4 6 6-4 4-6-6ZM10 53l4-4 6 6-4 4-6-6Zm34 2 6-6 4 4-6 6-4-4Z"/></symbol>
<symbol id="spikes" viewBox="0 0 64 64"><path fill="currentColor" d="m9 47 9-32 9 32H9Zm18 0 9-32 9 32H27Zm18 0 9-32 9 32H45ZM5 51h54v9H5v-9Z"/></symbol>
<symbol id="eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="currentColor"/></symbol>
<symbol id="arrow" viewBox="0 0 24 24"><path d="M12 2l7 9h-4v11H9V11H5l7-9Z" fill="currentColor"/></symbol>
<symbol id="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>
<symbol id="motion" viewBox="0 0 24 24"><path d="M4 12h6m4 0h6M8 7l5 5-5 5M16 7l-5 5 5 5" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M9.5 9a2.7 2.7 0 1 1 4.2 2.2c-1.4.9-1.7 1.4-1.7 2.8M12 17h.01" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="hand" viewBox="0 0 24 24"><path d="M8 12V5a1.5 1.5 0 0 1 3 0v5V3.5a1.5 1.5 0 0 1 3 0V10V5a1.5 1.5 0 0 1 3 0v6V8a1.5 1.5 0 0 1 3 0v6c0 4-3 7-7 7-3 0-5-2-6-5l-1-3a1.5 1.5 0 0 1 2-1z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></symbol>
<symbol id="exit" viewBox="0 0 24 24"><path d="M4 3h12v18H4zM16 8h4v13H8" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linejoin="round"/><path d="m11 12 5-4m-5 4 5 4m-5-4H2" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="friends" viewBox="0 0 24 24"><circle cx="8" cy="8" r="3" fill="none" stroke="currentColor" stroke-width="2.5"/><circle cx="17" cy="10" r="3" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M3 21c0-4 2-7 5-7s5 3 5 7m1-1c.5-3 1.7-5 4-5 2 0 3 2 3 5" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="maze" viewBox="0 0 160 112"><path d="M8 8h144v96H8V8Zm20 0v24H8m44-24v20h28v28H52v24H28v24m52-48h28V32h24v48h20M80 80v24m28-48v48" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="square"/><circle cx="20" cy="92" r="7" fill="currentColor"/><path d="m135 89 9 7-9 7" fill="none" stroke="currentColor" stroke-width="6"/></symbol>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

+21 -15
View File
@@ -9,12 +9,23 @@
</head>
<body>
<main id="app">
<div class="message-rail" id="notice" role="status" aria-live="polite" aria-atomic="true" hidden></div>
<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>
@@ -34,8 +45,8 @@
<div class="lobby-grid">
<article class="panel roster-panel">
<h2>Игроки</h2>
<div class="player-card player-one" id="player-1"><span class="avatar">P1</span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
<div class="player-card player-two" id="player-2"><span class="avatar">P2</span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
<div class="player-card player-one" id="player-1"><span class="avatar" aria-label="Жёлтый игрок"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg></span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
<div class="player-card player-two" id="player-2"><span class="avatar" aria-label="Синий игрок"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg></span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
</article>
<article class="panel observer-panel"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><h2>Зрительный зал</h2><p><strong id="observer-count">0</strong> / 4 эксперта по чужим ошибкам.</p></article>
<article class="panel launch-panel"><h2>Большая красная кнопка</h2><p id="launch-help">Старт доступен Игроку 1, когда оба слота на месте.</p><button class="button primary launch-button" id="start-match" type="button" disabled>Начать 3—2—1</button></article>
@@ -44,7 +55,7 @@
<section class="screen match-screen" id="match-screen" hidden aria-label="Игровой экран">
<div class="player-status" id="player-status">
<div class="identity" id="identity"><span class="avatar character-avatar" id="self-slot" aria-hidden="true">●</span><span><strong id="self-name">Неизвестный герой</strong><small id="match-state">Лабиринт думает</small></span></div>
<div class="identity" id="identity"><span class="avatar character-avatar" id="self-slot" aria-hidden="true"><svg><use href="/icons.svg#player"></use></svg></span><span><strong id="self-name">Неизвестный герой</strong><small id="match-state">Лабиринт думает</small></span></div>
<div class="stat" id="inventory-stat"><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><span><small>Ключ</small><strong id="inventory">Пусто</strong></span></div>
<div class="stat" id="vision-stat"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><span><small>Суперзрение</small><strong id="vision">Нет</strong></span></div>
<div class="stat compass-stat" id="compass-stat"><svg id="compass-arrow" aria-hidden="true"><use href="/icons.svg#arrow"></use></svg><span><small>Компас</small><strong id="compass">Молчит</strong></span></div>
@@ -52,18 +63,13 @@
<div class="map-shell" id="map-shell" tabindex="0" aria-label="Лабиринт. Используйте стрелки, WASD или свайпы для движения">
<div class="unknown-message" id="map-placeholder"><svg aria-hidden="true"><use href="/icons.svg#radar"></use></svg><span>Ждём разрешённый сервером кусочек карты…</span></div>
<div class="maze" id="maze" role="img" aria-label="Видимая часть лабиринта"></div>
<button class="avatar-control" id="avatar-control" type="button" aria-label="Тянуть героя для движения"><span class="avatar-handle" id="avatar-handle" aria-hidden="true"><span id="avatar-handle-symbol">●</span><svg><use href="/icons.svg#arrow"></use></svg></span></button>
<div class="wall-bump" id="wall-bump" role="status" aria-live="polite"></div>
<button class="avatar-control" id="avatar-control" type="button" aria-label="Тянуть героя для движения"><span class="avatar-handle" id="avatar-handle" aria-hidden="true"><svg class="avatar-player-icon"><use href="/icons.svg#player"></use></svg><svg class="avatar-direction-icon"><use href="/icons.svg#arrow"></use></svg></span></button>
<div class="wall-bump" id="wall-bump" aria-hidden="true"><svg hidden><use href="/icons.svg#spikes"></use></svg></div>
</div>
<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="Двигаться вверх">▲</button><button type="button" data-direction="west" aria-label="Двигаться влево">◀</button><span aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо">▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз">▼</button></nav>
<nav class="dpad" aria-label="Управление движением"><button type="button" data-direction="north" aria-label="Двигаться вверх" disabled>▲</button><button type="button" data-direction="west" aria-label="Двигаться влево" disabled>◀</button><span class="dpad-center" aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо" disabled>▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз" disabled>▼</button></nav>
<section class="onboarding" id="onboarding" hidden aria-label="Обучение игре">
<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>
</section>
@@ -72,7 +78,7 @@
<section class="screen observer-screen" id="observer-screen" hidden aria-labelledby="observer-title">
<div class="observer-toolbar">
<div><p class="eyebrow">Режим наблюдателя · только чтение</p><h1 id="observer-title">Весь куст целиком</h1></div>
<div class="observer-players" aria-live="polite"><span class="player-one">P1 <strong id="observer-p1">готов</strong></span><span class="player-two">P2 <strong id="observer-p2">готов</strong></span><span id="observer-phase">Ждём старта</span></div>
<div class="observer-players" aria-live="polite"><span class="player-one"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg> Жёлтый <strong id="observer-p1">готов</strong></span><span class="player-two"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg> Синий <strong id="observer-p2">готов</strong></span><span id="observer-phase">Ждём старта</span></div>
<div class="zoom-controls" aria-label="Масштаб карты"><button id="zoom-out" type="button" aria-label="Уменьшить масштаб">−</button><button id="zoom-reset" type="button">Вся карта</button><button id="zoom-in" type="button" aria-label="Увеличить масштаб">+</button></div>
</div>
<div class="observer-requirement" id="observer-requirement" role="status"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><strong>Нужен экран побольше</strong><span>Зрительный зал открывается от 1024 × 768 пикселей.</span></div>
@@ -80,10 +86,10 @@
<div class="observer-maze" id="observer-maze" role="img" aria-label="Полная карта лабиринта"></div>
</div>
</section>
<div class="notice" id="notice" role="status" aria-live="polite">Сервер решает всё. Мы отвечаем за драматичное мерцание.</div>
</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>
+111 -55
View File
@@ -8,14 +8,15 @@
--ink: #f6efff;
--muted: #b7a8ca;
--aqua: #43ead2;
--p1: #58aaff;
--p2: #ff72bd;
--p1: #ffd33d;
--p2: #41b6ff;
--plant: #45b86b;
--plant-dark: #176638;
--success: #72e48c;
--warning: #ffd65a;
--warning: #ffad38;
--vision: #37c9ff;
--error: #ff647a;
--pause: #5598ff;
--pause: #9b8cff;
--disconnect: #ff9f43;
--cell: 40px;
--shadow: #040207;
@@ -35,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; }
@@ -84,8 +94,10 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.player-card.player-one { border-left: 5px solid var(--p1); }
.player-card.player-two { border-left: 5px solid var(--p2); }
.avatar { width: 46px; height: 46px; display: grid; place-items: center; border: 2px solid currentColor; background: #0b0712; font-weight: 1000; }
.avatar svg { width: 82%; height: 82%; }
.player-one .avatar, .identity.player-one .avatar { color: var(--p1); }
.player-two .avatar, .identity.player-two .avatar { color: var(--p2); }
.player-two .avatar { border-style: dashed; }
.player-card strong, .player-card small, .identity strong, .identity small, .stat small, .stat strong { display: block; }
.player-card small, .identity small, .stat small { margin-top: 4px; color: var(--muted); font-size: .66rem; }
.status-dot { width: 10px; height: 10px; background: var(--error); }
@@ -108,54 +120,82 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.map-shell:focus-visible { border-color: var(--aqua); }
.unknown-message { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 12px; padding: 24px; color: var(--muted); text-align: center; }
.unknown-message svg { width: 54px; height: 54px; color: var(--aqua); animation: radar 1.8s linear infinite; }
.maze { position: absolute; left: 50%; top: 50%; display: grid; transform: translate(-50%, -50%); }
.cell { position: relative; width: var(--cell); height: var(--cell); background: #11131a; border: 4px solid transparent; }
.cell.wall-n { border-top-color: var(--plant); box-shadow: inset 0 3px 0 var(--plant-dark); }
.cell.wall-e { border-right-color: var(--plant); }
.cell.wall-s { border-bottom-color: var(--plant); }
.cell.wall-w { border-left-color: var(--plant); }
.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; }
.hedge-edge.hedge-vertical { left: calc(var(--hedge-x) * var(--cell) - var(--cell) * .5); top: calc(var(--hedge-y) * var(--cell) + var(--cell) * .4); transform: rotate(90deg); }
.cell.bush-impact { z-index: 5; animation: bush-resist .34s steps(3, end); }
.cell.bush-stage-2 { filter: saturate(1.25); }
.cell.bush-stage-3 { filter: brightness(1.2) saturate(1.4); }
.cell.bush-stage-4 { filter: brightness(1.35) saturate(1.65); }
.object, .pawn { position: absolute; z-index: 3; inset: 16%; display: grid; place-items: center; }
.object svg { width: 100%; height: 100%; }
.object-key { color: var(--warning); }
.object-key::after { content: "●"; position: absolute; right: -5px; bottom: -6px; padding: 1px 3px; background: #090711; border: 1px solid currentColor; font-size: 8px; font-weight: 900; }
.object-key[data-owner="two"]::after { content: "◆"; }
.object-exit { inset: 8%; border: 3px double currentColor; background: #0b0911; }
.object svg, .pawn svg { width: 100%; height: 100%; }
.object-key { color: var(--p1); }
.object-key[data-owner="two"] { color: var(--p2); }
.object-key::after { content: ""; position: absolute; right: -4px; bottom: -4px; width: 8px; height: 8px; border: 2px solid currentColor; border-radius: 50%; background: #090711; }
.object-key[data-owner="two"]::after { border-radius: 0; transform: rotate(45deg); }
.object-exit { inset: 8%; background: #0b0911; }
.object-exit[data-owner="one"] { color: var(--p1); }
.object-exit[data-owner="two"] { color: var(--p2); transform: rotate(45deg); }
.object-exit[data-owner="two"] { color: var(--p2); }
.object-exit[data-owner="two"]::after { content: ""; position: absolute; inset: 6%; border: 2px dashed currentColor; }
.object-exit[data-open="1"] { background: #72e48c35; animation: exit-open .8s steps(2, end) infinite; }
.object-compass { color: var(--aqua); border: 2px dashed currentColor; border-radius: 50%; }
.object-landmark { color: var(--plant); font-size: 1.15rem; text-shadow: 2px 2px #000; }
.object-vision { color: var(--warning); border-radius: 50%; background: #ffd65a30; box-shadow: 0 0 10px var(--warning); animation: artifact 1.2s steps(4, end) infinite; }
.pawn { inset: 12%; border: 3px solid currentColor; background: #0b0712; font-size: .62rem; font-weight: 1000; box-shadow: 3px 3px 0 #000; animation: pawn-in .16s steps(2, end); }
.object-vision { color: var(--vision); border-radius: 50%; background: #37c9ff30; box-shadow: 0 0 10px var(--vision); animation: artifact 1.2s steps(4, end) infinite; }
.pawn { inset: 12%; padding: 5%; border: 3px solid currentColor; background: #0b0712; box-shadow: 3px 3px 0 #000; animation: pawn-in .16s steps(2, end); }
.pawn-self { color: var(--p1); }
.match-screen.player-two-mode .pawn-self { color: var(--p2); }
.pawn-opponent { color: var(--p2); transform: rotate(45deg); }
.pawn-opponent { color: var(--p2); }
.match-screen.player-two-mode .pawn-opponent { color: var(--p1); }
.pawn-opponent span { transform: rotate(-45deg); }
.wall-bump { position: absolute; left: 50%; bottom: 18px; z-index: 6; min-height: 28px; transform: translateX(-50%); padding: 6px 10px; color: var(--error); background: #260b12dd; opacity: 0; }
.wall-bump.show { animation: bump .8s steps(3, end); }
.wall-bump::before { content: "▌"; display: inline-block; margin-right: 6px; font-size: 1.35em; font-weight: 1000; }
.pawn-owner-two { border-style: dashed; }
.wall-bump { position: absolute; inset: 0; z-index: 6; border: 0 solid transparent; pointer-events: none; }
.wall-bump.show { border-color: var(--error); }
.maze.impact-north { animation: impact-north .28s steps(3, end); }
.maze.impact-east { animation: impact-east .28s steps(3, end); }
.maze.impact-south { animation: impact-south .28s steps(3, end); }
.maze.impact-west { animation: impact-west .28s steps(3, end); }
.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); }
[data-reduced-motion="true"] .wall-bump.impact-south { border-bottom-color: var(--error); }
[data-reduced-motion="true"] .wall-bump.impact-west { border-left-color: var(--error); }
.avatar-control { position: absolute; z-index: 8; left: 50%; top: 50%; width: 96px; height: 96px; margin: -48px; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: grab; touch-action: none; user-select: none; }
.avatar-control { position: absolute; z-index: 8; left: var(--player-focal-x, 50%); top: var(--player-focal-y, 50%); width: 96px; height: 96px; margin: -48px; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: grab; touch-action: none; user-select: none; }
.avatar-control:active, .avatar-control.active { cursor: grabbing; }
.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: #0b0712; color: var(--p1); box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead236; font-size: 1.25rem; font-weight: 1000; transform: translate(-50%, -50%); transition: transform .12s steps(3, end); }
.avatar-handle { position: absolute; left: 50%; top: 50%; width: 52px; height: 52px; display: grid; place-items: center; border: 3px solid currentColor; border-radius: 50%; background: transparent; color: var(--p1); box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead236; font-size: 1.25rem; font-weight: 1000; transform: translate(-50%, -50%); transition: transform .12s steps(3, end); }
.player-two-mode .avatar-handle { color: var(--p2); }
.avatar-control.active .avatar-handle { transition: none; }
.avatar-handle svg { 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-handle svg { display: block; }
.avatar-control[data-direction="north"] .avatar-handle svg { transform: rotate(0deg); }
.avatar-control[data-direction="east"] .avatar-handle svg { transform: rotate(90deg); }
.avatar-control[data-direction="south"] .avatar-handle svg { transform: rotate(180deg); }
.avatar-control[data-direction="west"] .avatar-handle svg { transform: rotate(270deg); }
.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; }
.avatar-control[data-direction="north"] .avatar-direction-icon { transform: rotate(0deg); }
.avatar-control[data-direction="east"] .avatar-direction-icon { transform: rotate(90deg); }
.avatar-control[data-direction="south"] .avatar-direction-icon { transform: rotate(180deg); }
.avatar-control[data-direction="west"] .avatar-direction-icon { transform: rotate(270deg); }
.avatar-control[data-speed="fast"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 4px var(--aqua), 0 0 18px #43ead299; }
.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; }
@@ -174,18 +214,20 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.drop-key strong { font-size: 1.2rem; }
.drop-key span { grid-column: 1 / -1; font-size: .56rem; text-transform: uppercase; }
.drop-key:disabled { opacity: .48; }
.dpad { position: fixed; z-index: 11; right: max(24px, env(safe-area-inset-right)); bottom: max(28px, env(safe-area-inset-bottom)); display: grid; grid-template-columns: repeat(3, 58px); grid-template-rows: repeat(2, 58px); gap: 5px; }
.dpad button { border: 2px solid var(--aqua); background: #102a30df; color: var(--aqua); box-shadow: 4px 4px 0 #020506; cursor: pointer; }
.dpad { position: fixed; z-index: 16; right: max(18px, env(safe-area-inset-right)); bottom: max(18px, env(safe-area-inset-bottom)); display: grid; grid-template-columns: repeat(3, 58px); grid-template-rows: repeat(3, 58px); gap: 5px; touch-action: none; user-select: none; -webkit-user-select: none; }
.dpad button { min-width: 58px; min-height: 58px; border: 2px solid var(--aqua); background: #102a30df; color: var(--aqua); box-shadow: 4px 4px 0 #020506; cursor: pointer; touch-action: none; user-select: none; -webkit-user-select: none; }
.dpad button.active, .dpad button:active { transform: translate(3px, 3px); border-style: dashed; box-shadow: 1px 1px 0 #020506; }
.dpad button:disabled { border-color: var(--edge); background: #171027c9; color: var(--muted); opacity: .55; cursor: not-allowed; }
.dpad button.pending { outline: 3px solid var(--warning); outline-offset: -5px; }
.dpad button:first-child { grid-column: 2; }
.dpad button:nth-child(2) { grid-column: 1; grid-row: 2; }
.dpad span { grid-column: 2; grid-row: 2; display: grid; place-items: center; color: #4b3a60; }
.dpad-center { grid-column: 2; grid-row: 2; display: grid; place-items: center; border: 2px solid #3b2951; background: #0d0915d9; color: #5a4174; pointer-events: none; }
.dpad button:nth-child(4) { grid-column: 3; grid-row: 2; }
.dpad button:last-child { grid-column: 2; grid-row: 3; }
.notice { position: fixed; z-index: 40; left: 50%; bottom: max(12px, env(safe-area-inset-bottom)); max-width: calc(100% - 24px); transform: translate(-50%, 130%); padding: 10px 14px; border-left: 4px solid var(--warning); background: #191020f2; color: var(--ink); box-shadow: 4px 4px 0 #020104; font-size: .76rem; opacity: 0; transition: .18s steps(3, end); pointer-events: none; }
.notice.visible { opacity: 1; transform: translate(-50%, 0); }
.notice.error { border-color: var(--error); }
.notice.success { border-color: var(--success); }
.message-rail { position: fixed; top: max(4px, env(safe-area-inset-top)); z-index: 45; left: 50%; width: min(620px, calc(100% - 20px)); transform: translateX(-50%); padding: 8px 14px; border: 2px solid var(--warning); background: #191020f2; color: var(--ink); box-shadow: 4px 4px 0 #020104; font-size: .76rem; text-align: center; pointer-events: none; }
.message-rail.error { border-color: var(--error); }
.message-rail.success { border-color: var(--success); }
.modal { position: fixed; z-index: 50; inset: 0; display: grid; place-items: center; padding: 18px; background: #050308dc; backdrop-filter: blur(5px); }
.modal-card { width: min(440px, 100%); padding: 30px; border: 3px solid var(--aqua); background: #130b22; text-align: center; box-shadow: 9px 9px 0 #020103; animation: modal-in .28s steps(4, end); }
.modal-card > svg { width: 54px; height: 54px; color: var(--aqua); }
@@ -199,21 +241,23 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.observer-players { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: .72rem; }
.observer-players span { padding: 7px; border: 1px solid #51386e; }
.observer-players .player-one { border-color: var(--p1); }
.observer-players .player-two { border-color: var(--p2); }
.observer-players .player-two { border-color: var(--p2); border-style: dashed; }
.observer-players svg { width: 16px; height: 16px; vertical-align: middle; }
.zoom-controls { display: flex; gap: 5px; }
.zoom-controls button { min-width: 42px; min-height: 38px; border: 1px solid var(--aqua); background: #102a30; color: var(--aqua); cursor: pointer; }
.observer-stage { position: absolute; inset: 92px 0 0; overflow: hidden; border: 2px solid #2f2041; background: #050408; cursor: grab; touch-action: none; user-select: none; }
.observer-stage.dragging { cursor: grabbing; }
.observer-maze { position: absolute; left: 50%; top: 50%; display: grid; transform-origin: center; will-change: transform; }
.observer-maze .cell { width: 8px; height: 8px; border-width: 1px; }
.observer-maze .cell { width: 8px; height: 8px; }
.observer-maze .cell::after { inset: 2px; }
.observer-maze .hedge-edge { height: 2px; top: calc(var(--hedge-y) * var(--cell) - 1px); }
.observer-maze .hedge-edge.hedge-vertical { left: calc(var(--hedge-x) * var(--cell) - var(--cell) * .5); top: calc(var(--hedge-y) * var(--cell) + var(--cell) * .5 - 1px); }
.observer-maze .object { inset: 5%; }
.observer-maze .object-key::after { display: none; }
.observer-maze .object-landmark { font-size: 6px; }
.observer-maze .pawn { inset: -30%; z-index: 5; font-size: 5px; border-width: 1px; box-shadow: 1px 1px 0 #000; }
.observer-maze .pawn { inset: -30%; z-index: 5; padding: 1px; border-width: 1px; box-shadow: 1px 1px 0 #000; }
.observer-maze .pawn-player-one { color: var(--p1); }
.observer-maze .pawn-player-two { color: var(--p2); transform: rotate(45deg); }
.observer-maze .pawn-player-two span { transform: rotate(-45deg); }
.observer-maze .pawn-player-two { color: var(--p2); }
.observer-requirement { display: none; position: absolute; inset: 92px 0 0; place-content: center; justify-items: center; gap: 12px; padding: 24px; color: var(--muted); text-align: center; border: 2px solid var(--edge); background: var(--panel-deep); }
.observer-requirement svg { width: 58px; height: 58px; color: var(--p2); }
.observer-requirement strong { color: var(--ink); font-size: 1.2rem; text-transform: uppercase; }
@@ -227,7 +271,9 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
@media (max-width: 720px) {
:root { --cell: 46px; }
#app { padding-left: max(8px, env(safe-area-inset-left)); padding-right: max(8px, env(safe-area-inset-right)); }
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; }
@@ -240,7 +286,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.player-card { margin-top: 8px; padding: 8px; }
.roster-panel { grid-row: auto; }
.section-heading { align-items: start; flex-direction: column; }
.match-screen { height: calc(100dvh - 70px); padding-top: 82px; }
.match-screen { height: calc(100dvh - 70px); min-height: 0; padding-top: 82px; }
.player-status { grid-template-columns: 1.25fr repeat(3, .75fr); inset: 6px 0 auto; }
.identity, .stat { min-width: 0; min-height: 72px; padding: 5px; flex-direction: column; justify-content: center; text-align: center; gap: 1px; }
.identity .avatar { flex-basis: 34px; width: 34px; height: 34px; font-size: 1.4rem; }
@@ -251,9 +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; }
.dpad { display: none; }
.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; }
}
@@ -269,10 +317,11 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.identity, .stat { min-height: 55px; }
.map-shell { inset: 64px 0 0; }
.drop-key { top: 76px; right: 14px; width: 70px; height: 70px; }
.dpad { right: max(12px, env(safe-area-inset-right)); bottom: max(8px, env(safe-area-inset-bottom)); grid-template-columns: repeat(3, 52px); grid-template-rows: repeat(3, 52px); }
.dpad button { min-width: 52px; min-height: 52px; }
}
@media (hover: none) and (pointer: coarse) and (max-height: 720px) {
.dpad { display: none; }
.drop-key { top: auto; bottom: max(14px, env(safe-area-inset-bottom)); }
}
@@ -284,7 +333,14 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
@keyframes artifact { 50% { transform: scale(.75); box-shadow: 0 0 18px var(--warning); } }
@keyframes exit-open { 50% { outline: 3px solid var(--success); } }
@keyframes pawn-in { from { transform: scale(.6); } }
@keyframes bush-resist { 50% { transform: translateX(3px) rotate(1deg); } }
@keyframes impact-north { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) - 10px)); } }
@keyframes impact-east { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px) + 10px), calc(-50% + var(--map-offset-y, 0px))); } }
@keyframes impact-south { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) + 10px)); } }
@keyframes impact-west { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px) - 10px), calc(-50% + var(--map-offset-y, 0px))); } }
@keyframes bump { 0%, 100% { opacity: 0; } 20%, 75% { opacity: 1; transform: translateX(-50%) translateX(4px); } 45% { transform: translateX(-50%) translateX(-4px); } }
@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); } }
+545 -77
View File
@@ -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;
@@ -14,15 +15,19 @@
const AVATAR_DIRECTION_HYSTERESIS = .18;
const AVATAR_SLOW_REPEAT_MS = 360;
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: "Рука остановилась — шаги тоже."}),
Object.freeze({icon: "maze", title: "Стена не пускает", text: "Упрись в стену — попробуй другую сторону."}),
Object.freeze({icon: "spikes", title: "Стена не пускает", text: "Упрись в стену — попробуй другую сторону."}),
Object.freeze({icon: "key", title: "Найди ключ", text: "Ключ открывает твой выход."}),
Object.freeze({icon: "eye", title: "Глаз расширяет карту", text: "Возьми глаз — увидишь дальше."}),
Object.freeze({icon: "crystal", title: "Кристалл расширяет карту", text: "Возьми кристалл — увидишь дальше."}),
Object.freeze({icon: "arrow", title: "Стрелка знает путь", text: "Компас показывает сторону выхода."}),
Object.freeze({icon: "exit", title: "Выходи с ключом", text: "Коснись своего выхода с ключом."}),
Object.freeze({icon: "exit-closed", title: "Выходи с ключом", text: "Коснись своего выхода с ключом."}),
Object.freeze({icon: "friends", title: "Встретил друга", text: "Другой герой может отдать найденный ключ."})
]);
const NAME_ADJECTIVES = Object.freeze(["Бодрый", "Хитрый", "Тихий", "Смелый", "Ловкий", "Сонный", "Шустрый", "Лунный", "Яркий", "Дикий", "Вечный", "Храбрый"]);
@@ -47,7 +52,7 @@
const HINTS = Object.freeze({none: "Молчит", n: "Север", ne: "Северо-восток", e: "Восток", se: "Юго-восток", s: "Юг", sw: "Юго-запад", w: "Запад", nw: "Северо-запад"});
const HINT_ANGLES = Object.freeze({none: 0, n: 0, ne: 45, e: 90, se: 135, s: 180, sw: 225, w: 270, nw: 315});
const PHASES = Object.freeze({countdown: "Обратный отсчёт", waiting: "Ждём игроков", active: "Гонка идёт", finish_pending: "Фотофиниш", paused: "Пауза", finished: "Финиш"});
const ERRORS = Object.freeze({invalid_name: "Позывной должен быть длиной от 1 до 32 символов без пробелов по краям.", duplicate_name: "Этот позывной уже занят. Клоны сегодня не участвуют.", role_full: "Свободных мест больше нет.", invalid_token: "Старая сессия не найдена. Выбираем новый позывной.", session_connected: "Эта сессия уже открыта в другом окне.", not_owner: "Запустить гонку может только Игрок 1.", not_ready: "Для старта нужны оба игрока онлайн.", invalid_phase: "Сейчас эту команду выполнять нельзя.", rate_limited: "Слишком быстро! Сервер просит немного меньше турбо.", blocked: "Там стена. Очень убедительная.", no_key: "Карман пуст — бросать нечего.", player_exited: "Герой уже снаружи. Возвращаться за тапочками поздно.", match_finished: "Гонка уже закончилась.", stale_sequence: "Команда устарела и отправилась в музей.", unauthorized: "Сначала выберите роль или восстановите сессию.", text_required: "Сервер принимает только текстовые команды.", protocol_error: "Пакет не прошёл проверку протокола.", request_rejected: "Сервер отклонил команду.", no_suggestion: "Все смешные имена уже разобрали."});
const ERRORS = Object.freeze({invalid_name: "Позывной должен быть длиной от 1 до 32 символов без пробелов по краям.", duplicate_name: "Этот позывной уже занят. Клоны сегодня не участвуют.", role_full: "Свободных мест больше нет.", invalid_token: "Старая сессия не найдена. Выбираем новый позывной.", session_connected: "Эта сессия уже открыта в другом окне.", not_owner: "Запустить гонку может только Игрок 1.", not_ready: "Для старта нужны оба игрока онлайн.", invalid_phase: "Сейчас эту команду выполнять нельзя.", no_key: "Карман пуст — бросать нечего.", player_exited: "Герой уже снаружи. Возвращаться за тапочками поздно.", match_finished: "Гонка уже закончилась.", stale_sequence: "Команда устарела и отправилась в музей.", unauthorized: "Сначала выберите роль или восстановите сессию.", text_required: "Команда потеряла слова по дороге.", protocol_error: "Лабиринт не понял этот знак.", request_rejected: "Лабиринт пока не принимает эту затею.", no_suggestion: "Все смешные имена уже разобрали."});
function randomIndex(limit, randomSource) {
if (!Number.isInteger(limit) || limit < 1) return 0;
@@ -147,13 +152,13 @@
this.latest = null;
}
request(direction) {
request(direction, element) {
if (!direction) return null;
if (this.inFlight) {
this.latest = direction;
this.latest = {direction, element};
return null;
}
const requestId = this.dispatch(direction);
const requestId = this.dispatch(direction, element);
if (requestId) this.inFlight = requestId;
return requestId;
}
@@ -161,9 +166,9 @@
complete(requestId) {
if (!requestId || requestId !== this.inFlight) return null;
this.inFlight = null;
const direction = this.latest;
const latest = this.latest;
this.latest = null;
return direction ? this.request(direction) : null;
return latest ? this.request(latest.direction, latest.element) : null;
}
release() { this.latest = null; }
@@ -189,6 +194,18 @@
};
}
function playerFocalPoint(stageWidth, stageHeight) {
if (![stageWidth, stageHeight].every(value => Number.isFinite(value) && value > 0)) return {x: 0, y: 0};
if (stageWidth > stageHeight) return {x: Math.round(stageWidth * .42), y: Math.round(stageHeight * .45)};
return {x: Math.round(stageWidth / 2), y: Math.round(stageHeight * .3)};
}
function playerFocalMapOffset(selfIndex, width, minX, minY, columns, rows, cellSize, stageWidth, stageHeight) {
const centered = playerMapOffset(selfIndex, width, minX, minY, columns, rows, cellSize);
const focal = playerFocalPoint(stageWidth, stageHeight);
return {x: centered.x + focal.x - stageWidth / 2, y: centered.y + focal.y - stageHeight / 2};
}
function nextOnboardingStep(currentStep) {
return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1;
}
@@ -236,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 || {}};
}
@@ -245,7 +306,7 @@
return false;
}
const cellLimit = snapshot.width * snapshot.height;
return snapshot.cells.every(cell => Array.isArray(cell) && cell.length === 2 && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 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 {
@@ -289,6 +350,48 @@
return snapshot && Array.isArray(snapshot.cells) ? snapshot.cells.map(cell => cell[0]) : [];
}
function hedgeEdgeKey(x, y, vertical) {
return `${vertical ? "v" : "h"}:${x}:${y}`;
}
function hedgeVariant(edge, count) {
const seed = ((edge.x + 1) * 1103515245 + (edge.y + 1) * 12345 + (edge.vertical ? 97 : 13)) >>> 0;
return seed % count + 1;
}
function projectedHedgeEdges(cells, width) {
if (!Array.isArray(cells) || !Number.isInteger(width) || width < 1) return [];
const present = new Set(cells.map(cell => cell[0]));
const edges = new Map();
const add = (x, y, vertical, opened) => {
const key = hedgeEdgeKey(x, y, vertical);
const existing = edges.get(key);
edges.set(key, {key, x, y, vertical, opened: !!opened || !!(existing && existing.opened)});
};
cells.forEach(cell => {
if (!Array.isArray(cell) || cell.length < 3 || !Number.isInteger(cell[0])) return;
const index = cell[0]; const passages = cell[1]; const opened = cell[2];
const x = index % width; const y = Math.floor(index / width);
const eastVisible = present.has(index + 1) && (index + 1) % width !== 0;
const southVisible = present.has(index + width);
if (!(passages & 1) || (opened & 1)) add(x, y, false, opened & 1);
if (!(passages & 8) || (opened & 8)) add(x, y, true, opened & 8);
if ((!eastVisible && !(passages & 2)) || (opened & 2)) add(x + 1, y, true, opened & 2);
if ((!southVisible && !(passages & 4)) || (opened & 4)) add(x, y + 1, false, opened & 4);
});
return Array.from(edges.values());
}
function hedgeKeyForMove(cellIndex, width, direction) {
if (!Number.isInteger(cellIndex) || !Number.isInteger(width) || width < 1) return null;
const x = cellIndex % width; const y = Math.floor(cellIndex / width);
if (direction === "north") return hedgeEdgeKey(x, y, false);
if (direction === "east") return hedgeEdgeKey(x + 1, y, true);
if (direction === "south") return hedgeEdgeKey(x, y + 1, false);
if (direction === "west") return hedgeEdgeKey(x, y, true);
return null;
}
function clampObserverZoom(value) {
return Math.min(6, Math.max(.25, value));
}
@@ -300,15 +403,65 @@
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, 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}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
state.moveFlow = new LatestIntentFlow(direction => sendMoveNow(direction));
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) {
function showNotice(text, kind, priority, persistent, key) {
const element = byId("notice");
const nextPriority = priority == null ? 1 : priority;
if (state.rail.persistent && !persistent && nextPriority < state.rail.priority) return;
if (state.rail.key === key && element.textContent === text) return;
element.textContent = text;
element.className = `notice visible ${kind || ""}`;
element.className = `message-rail ${kind || ""}`;
element.hidden = false;
windowObject.clearTimeout(state.noticeTimer);
state.noticeTimer = windowObject.setTimeout(() => element.classList.remove("visible"), 2600);
state.rail = {priority: nextPriority, key: key || null, persistent: !!persistent};
if (!persistent) state.noticeTimer = windowObject.setTimeout(() => { element.hidden = true; state.rail = {priority: 0, key: null, persistent: false}; }, 2600);
}
function clearEffects() {
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"));
}
function showImpact(direction, hits) {
if (!direction) return;
const maze = byId("maze");
const bump = byId("wall-bump");
windowObject.clearTimeout(state.effects.impactTimer);
maze.classList.remove("impact-north", "impact-east", "impact-south", "impact-west");
void maze.offsetWidth;
maze.classList.add(`impact-${direction}`);
bump.classList.remove("impact-north", "impact-east", "impact-south", "impact-west");
bump.classList.add(`impact-${direction}`);
bump.classList.add("show");
const stage = Math.min(4, Math.max(1, Number.isInteger(hits) ? hits : 1));
const target = state.snapshot && documentObject.querySelector(`.cell[data-cell-index="${state.snapshot.self}"]`);
if (target && Number.isInteger(hits) && hits > 0) target.classList.add("bush-impact", `bush-stage-${stage}`);
const hedgeKey = state.snapshot && hedgeKeyForMove(state.snapshot.self, state.snapshot.width, direction);
const hedge = hedgeKey && maze.querySelector(`[data-hedge-key="${hedgeKey}"]`);
const hedgeUse = hedge && hedge.querySelector("use");
if (hedgeUse && Number.isInteger(hits) && hits > 0) hedgeUse.setAttribute("href", `/hedges.svg#hedge-impact-${stage}`);
state.effects.impactTimer = windowObject.setTimeout(() => { maze.classList.remove("impact-north", "impact-east", "impact-south", "impact-west"); bump.classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west"); if (target) target.classList.remove("bush-impact", `bush-stage-${stage}`); if (hedgeUse) hedgeUse.setAttribute("href", `/hedges.svg#${hedge.dataset.hedgeSymbol}`); }, 340);
}
function showRateLimit() {
const maze = byId("maze");
windowObject.clearTimeout(state.effects.rateTimer);
maze.classList.add("rate-limited");
state.effects.rateTimer = windowObject.setTimeout(() => maze.classList.remove("rate-limited"), 520);
}
function setScreen(name) {
@@ -316,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) {
@@ -329,10 +538,53 @@
byId("connection").dataset.state = connectionState;
byId("connection-text").textContent = text;
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
if (connectionState !== "online") resetAvatarControl();
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;
@@ -378,9 +630,10 @@
}
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);
if (type === "move") { state.latency.sent(requestId); state.moveDirections.set(requestId, payload.direction); }
return requestId;
}
@@ -417,32 +670,143 @@
return wrapper;
}
function hedgeSymbol(edge) {
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));
const newOpenedKeys = new Set([...openedKeys].filter(key => !state.hedge.openedKeys.has(key)));
const layer = documentObject.createElement("span");
layer.className = "hedge-layer";
edges.forEach(edge => {
const wrapper = documentObject.createElement("span");
const symbol = hedgeSymbol(edge);
wrapper.className = `hedge-edge ${edge.vertical ? "hedge-vertical" : "hedge-horizontal"}`;
wrapper.dataset.hedgeKey = edge.key;
wrapper.dataset.hedgeSymbol = symbol;
wrapper.style.setProperty("--hedge-x", String(edge.x - minX));
wrapper.style.setProperty("--hedge-y", String(edge.y - minY));
const svg = documentObject.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 160 40");
svg.setAttribute("preserveAspectRatio", "none");
const use = documentObject.createElementNS("http://www.w3.org/2000/svg", "use");
use.setAttribute("href", `/hedges.svg#${symbol}`);
svg.appendChild(use);
wrapper.appendChild(svg);
layer.appendChild(wrapper);
});
maze.appendChild(layer);
state.hedge.openedKeys = openedKeys;
if (newOpenedKeys.size && !state.reducedMotion) {
newOpenedKeys.forEach(key => {
const edge = layer.querySelector(`[data-hedge-key="${key}"]`);
const use = edge && edge.querySelector("use");
if (use) use.setAttribute("href", "/hedges.svg#hedge-breakthrough");
});
windowObject.clearTimeout(state.effects.breakthroughTimer);
state.effects.breakthroughTimer = windowObject.setTimeout(() => {
layer.querySelectorAll(".hedge-edge").forEach(edge => {
const use = edge.querySelector("use");
if (use) use.setAttribute("href", `/hedges.svg#${edge.dataset.hedgeSymbol}`);
});
}, 340);
}
}
function addObject(cell, object) {
const [kind, owner, variant] = object;
let marker;
const ownerVariant = owner === 1 ? "two" : "one";
if (kind === "key") {
marker = icon("key", "object object-key"); marker.dataset.owner = owner === 1 ? "two" : "one";
marker = icon("key", "object object-key"); marker.dataset.owner = ownerVariant;
} else if (kind === "exit") {
marker = documentObject.createElement("span"); marker.className = "object object-exit"; marker.dataset.owner = owner === 1 ? "two" : "one"; marker.dataset.open = String(variant);
marker = icon(variant ? "exit-open" : "exit-closed", "object object-exit"); marker.dataset.owner = ownerVariant; marker.dataset.open = String(variant);
} else if (kind === "compass") {
marker = icon("arrow", "object object-compass");
} else if (kind === "vision") {
marker = icon("eye", "object object-vision");
marker = icon("crystal", "object object-vision");
} else {
marker = documentObject.createElement("span"); marker.className = "object object-landmark"; marker.textContent = ["♣", "✿", "♠", "⌂"][variant % 4] || "♣";
}
cell.appendChild(marker);
}
function addPawn(cell, label, opponent) {
const pawn = documentObject.createElement("span");
pawn.className = opponent ? "pawn pawn-opponent" : "pawn pawn-self";
const text = documentObject.createElement("span"); text.textContent = label;
pawn.appendChild(text); cell.appendChild(pawn);
function addPawn(cell, slot, opponent) {
const pawn = icon("player", `pawn ${opponent ? "pawn-opponent" : "pawn-self"} pawn-owner-${slot === 2 ? "two" : "one"}`);
cell.appendChild(pawn);
}
function characterForSlot(slot) {
return slot === 2 ? {symbol: "◆", name: "Мышонок"} : {symbol: "●", name: "Лисёнок"};
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) {
@@ -452,33 +816,36 @@
byId("map-placeholder").hidden = false;
return;
}
const coordinates = snapshot.cells.map(cell => ({index: cell[0], x: cell[0] % snapshot.width, y: Math.floor(cell[0] / snapshot.width), passages: cell[1]}));
const coordinates = snapshot.cells.map(cell => ({index: cell[0], x: cell[0] % snapshot.width, y: Math.floor(cell[0] / snapshot.width), passages: cell[1], opened: cell[2]}));
const minX = Math.min(...coordinates.map(cell => cell.x)); const maxX = Math.max(...coordinates.map(cell => cell.x));
const minY = Math.min(...coordinates.map(cell => cell.y)); const maxY = Math.max(...coordinates.map(cell => cell.y));
const columns = maxX - minX + 1;
const rows = maxY - minY + 1;
const mapShell = byId("map-shell");
const cellSize = playerCellSize(mapShell.clientWidth, mapShell.clientHeight, columns, rows);
const offset = playerMapOffset(snapshot.self, snapshot.width, minX, minY, columns, rows, cellSize);
const focal = playerFocalPoint(mapShell.clientWidth, mapShell.clientHeight);
const offset = playerFocalMapOffset(snapshot.self, snapshot.width, minX, minY, columns, rows, cellSize, mapShell.clientWidth, mapShell.clientHeight);
maze.style.setProperty("--cell", `${cellSize}px`);
maze.style.gridTemplateColumns = `repeat(${columns}, var(--cell))`;
maze.style.gridTemplateRows = `repeat(${rows}, var(--cell))`;
maze.style.transform = `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px))`;
maze.style.setProperty("--map-offset-x", `${offset.x}px`);
maze.style.setProperty("--map-offset-y", `${offset.y}px`);
mapShell.style.setProperty("--player-focal-x", `${focal.x}px`);
mapShell.style.setProperty("--player-focal-y", `${focal.y}px`);
const objects = new Map();
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
coordinates.forEach(data => {
const cell = documentObject.createElement("span");
cell.className = "cell";
cell.dataset.cellIndex = String(data.index);
cell.style.gridColumn = String(data.x - minX + 1); cell.style.gridRow = String(data.y - minY + 1);
if (!(data.passages & 1)) cell.classList.add("wall-n");
if (!(data.passages & 2)) cell.classList.add("wall-e");
if (!(data.passages & 4)) cell.classList.add("wall-s");
if (!(data.passages & 8)) cell.classList.add("wall-w");
(objects.get(data.index) || []).forEach(object => addObject(cell, object));
const slot = state.session ? state.session.slot : 1;
if (data.index === snapshot.opponent) addPawn(cell, characterForSlot(slot === 1 ? 2 : 1).symbol, true);
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;
}
@@ -487,21 +854,22 @@
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;
const character = characterForSlot(slot);
byId("self-slot").textContent = character.symbol;
byId("self-slot").setAttribute("aria-label", character.name);
byId("avatar-handle-symbol").textContent = character.symbol;
byId("avatar-control").setAttribute("aria-label", `Тянуть героя ${character.name} для движения`);
byId("self-name").textContent = playerName();
byId("identity").className = `identity player-${slot === 1 ? "one" : "two"}`;
byId("match-screen").classList.toggle("player-two-mode", slot === 2);
byId("match-state").textContent = PHASES[snapshot.phase] || "Гонка идёт";
byId("inventory").textContent = snapshot.inventory == null ? "Пусто" : `Ключ ${characterForSlot(snapshot.inventory + 1).symbol}`;
byId("inventory").textContent = snapshot.inventory == null ? "Пусто" : `Ключ ${characterForSlot(snapshot.inventory + 1).keyName}`;
byId("inventory-stat").dataset.filled = String(snapshot.inventory != null);
byId("drop-key").disabled = snapshot.inventory == null;
setDirectionalPadEnabled(snapshot.phase === "active" && state.socket && state.socket.readyState === windowObject.WebSocket.OPEN);
byId("vision").textContent = snapshot.visionMoves ? `${snapshot.visionMoves} ход.` : "Нет";
byId("vision-stat").classList.toggle("vision-active", snapshot.visionMoves > 0);
const hint = HINTS[snapshot.hint] ? snapshot.hint : "none";
@@ -509,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") { resetAvatarControl(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
if (snapshot.phase === "paused") { resetPlayerControls(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
else {
hideModal();
if (!state.onboardingSeen) showOnboarding(0);
}
else hideModal();
}
function applyObserverTransform() {
@@ -541,32 +908,29 @@
setScreen("observer");
const maze = byId("observer-maze");
maze.replaceChildren();
maze.style.setProperty("--cell", `${OBSERVER_CELL_SIZE}px`);
maze.style.gridTemplateColumns = `repeat(${snapshot.width}, ${OBSERVER_CELL_SIZE}px)`;
maze.style.gridTemplateRows = `repeat(${snapshot.height}, ${OBSERVER_CELL_SIZE}px)`;
const objects = new Map();
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
const players = Array.isArray(snapshot.players) ? snapshot.players : [];
snapshot.cells.forEach(data => {
const index = data[0]; const passages = data[1];
const index = data[0]; const passages = data[1]; const opened = data[2];
const cell = documentObject.createElement("span");
cell.className = "cell";
cell.style.gridColumn = String(index % snapshot.width + 1);
cell.style.gridRow = String(Math.floor(index / snapshot.width) + 1);
if (!(passages & 1)) cell.classList.add("wall-n");
if (!(passages & 2)) cell.classList.add("wall-e");
if (!(passages & 4)) cell.classList.add("wall-s");
if (!(passages & 8)) cell.classList.add("wall-w");
(objects.get(index) || []).forEach(object => addObject(cell, object));
players.forEach((player, playerIndex) => {
if (player.cell === index && !player.exited) {
const pawn = documentObject.createElement("span");
pawn.className = `pawn pawn-player-${playerIndex === 0 ? "one" : "two"}`;
const label = documentObject.createElement("span"); label.textContent = `P${playerIndex + 1}`;
pawn.appendChild(label); cell.appendChild(pawn);
const pawn = icon("player", `pawn pawn-player-${playerIndex === 0 ? "one" : "two"} pawn-owner-${playerIndex === 0 ? "one" : "two"}`);
cell.appendChild(pawn);
}
});
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}`;
byId(`observer-p${index + 1}`).textContent = !player.connected ? "нет связи" : player.exited ? "снаружи" : key;
@@ -580,7 +944,8 @@
else if (snapshot.phase !== "paused") hideModal();
}
function showModal(title, text, kicker, countdown) {
function showModal(title, text, kicker, countdown, iconName) {
byId("modal-icon").querySelector("use").setAttribute("href", `/icons.svg#${iconName || "hourglass"}`);
byId("modal-title").textContent = title; byId("modal-text").textContent = text; byId("modal-kicker").textContent = kicker;
byId("countdown").hidden = !countdown; byId("modal").hidden = false;
}
@@ -614,8 +979,8 @@
title = slot ? (winner === slot ? "Победа!" : "Победил соперник") : `Победил Игрок ${winner}`;
text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат.";
}
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false);
resetAvatarControl();
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false, "exit-open");
resetPlayerControls();
byId("modal-action").textContent = state.session && state.session.role === "player" && state.session.slot === 1 ? "Новая гонка" : "Вернуться в лобби";
byId("modal-action").hidden = false;
}
@@ -627,7 +992,7 @@
const timer = windowObject.setInterval(() => { index++; if (index < steps.length) byId("countdown").textContent = steps[index]; else windowObject.clearInterval(timer); }, 1000);
}
function localError(code) { return ERRORS[code] || "Сервер отклонил команду. Подробности остались у него."; }
function localError(code) { return ERRORS[code] || "Лабиринт пока не принимает эту затею."; }
function handleMessage(message) {
if (!message || message.v !== 1 || !Number.isInteger(message.seq)) return;
@@ -643,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);
@@ -651,12 +1018,24 @@
} else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false);
} 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(); resetAvatarControl(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
} else if (message.type === "ack") { state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); state.moveFlow.complete(message.requestId); feedback(true);
} 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") {
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);
state.moveFlow.complete(message.requestId);
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
if (message.payload.code === "blocked") { const bump = byId("wall-bump"); bump.textContent = localError("blocked"); bump.classList.remove("show"); void bump.offsetWidth; bump.classList.add("show"); }
if (message.payload.code === "blocked") { showImpact(direction, message.payload.breakthroughHits); feedback(false); return; }
if (message.payload.code === "rate_limited") { showRateLimit(); feedback(false); return; }
showNotice(localError(message.payload.code), "error"); feedback(false);
}
}
@@ -666,17 +1045,45 @@
const scheme = windowObject.location.protocol === "https:" ? "wss" : "ws";
state.socket = new windowObject.WebSocket(`${scheme}://${windowObject.location.host}/ws`);
state.socket.onopen = () => { setConnection("online", "ESP на связи"); const token = windowObject.localStorage.getItem(TOKEN_KEY); if (token) send("restore", {token}, null); };
state.socket.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.onerror = () => showNotice("WebSocket споткнулся. Пробуем снова…", "error");
state.socket.onmessage = event => { let message; try { message = JSON.parse(event.data); } catch (_) { showNotice("Лабиринт прислал каракули. Ждём понятный знак.", "error"); return; } handleMessage(message); };
state.socket.onclose = () => { state.moveFlow.disconnect(); stopHeartbeat(); setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
state.socket.onerror = () => showNotice("Связь чихнула. Пробуем снова…", "error");
}
function move(direction, element) {
if (direction) state.moveFlow.request(direction, element);
}
function sendMoveNow(direction) {
return send("move", {direction}, byId("avatar-control"));
function sendMoveNow(direction, element) {
return send("move", {direction}, element || byId("avatar-control"));
}
function setDirectionalPadEnabled(enabled) {
documentObject.querySelectorAll("[data-direction]").forEach(button => {
button.disabled = !enabled;
});
if (!enabled) resetDirectionalPad();
}
function resetDirectionalPad() {
windowObject.clearTimeout(state.dpad.repeatTimer);
state.dpad.pointerId = null;
state.dpad.direction = null;
state.dpad.repeatTimer = null;
documentObject.querySelectorAll("[data-direction]").forEach(button => button.classList.remove("active"));
state.moveFlow.release();
}
function repeatDirectionalPadMove() {
if (!state.dpad.direction || state.dpad.pointerId == null) return;
move(state.dpad.direction, documentObject.querySelector(`[data-direction="${state.dpad.direction}"]`));
state.dpad.repeatTimer = windowObject.setTimeout(repeatDirectionalPadMove, DIRECTIONAL_PAD_REPEAT_MS);
}
function resetPlayerControls() {
resetAvatarControl();
resetDirectionalPad();
stopBreakthroughTravel();
}
function resetAvatarControl() {
@@ -730,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); }
@@ -771,13 +1188,60 @@
byId("avatar-control").addEventListener("pointerup", stopAvatarControl);
byId("avatar-control").addEventListener("pointercancel", stopAvatarControl);
byId("avatar-control").addEventListener("lostpointercapture", stopAvatarControl);
windowObject.addEventListener("blur", resetAvatarControl);
documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetAvatarControl(); });
documentObject.querySelectorAll("[data-direction]").forEach(button => button.addEventListener("click", () => move(button.dataset.direction, button)));
windowObject.addEventListener("blur", resetPlayerControls);
const refreshPlayerFocalPoint = () => {
if (state.snapshot && state.snapshot.role === "player") windowObject.requestAnimationFrame(() => renderMap(state.snapshot));
};
windowObject.addEventListener("resize", refreshPlayerFocalPoint);
windowObject.addEventListener("orientationchange", refreshPlayerFocalPoint);
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;
event.preventDefault();
beginFeedback();
resetDirectionalPad();
state.dpad.pointerId = event.pointerId;
state.dpad.direction = button.dataset.direction;
button.classList.add("active");
if (button.setPointerCapture) button.setPointerCapture(event.pointerId);
move(state.dpad.direction, button);
state.dpad.repeatTimer = windowObject.setTimeout(repeatDirectionalPadMove, DIRECTIONAL_PAD_REPEAT_MS);
});
const stopDirectionalPad = event => {
if (event.pointerId === state.dpad.pointerId) resetDirectionalPad();
};
button.addEventListener("pointerup", stopDirectionalPad);
button.addEventListener("pointercancel", stopDirectionalPad);
button.addEventListener("lostpointercapture", stopDirectionalPad);
button.addEventListener("contextmenu", event => event.preventDefault());
button.addEventListener("click", event => {
if (event.detail === 0 && !button.disabled) move(button.dataset.direction, button);
});
});
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();
@@ -802,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"));
@@ -814,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_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, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, 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); });