Compare commits

...

6 Commits

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

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

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

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

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