Compare commits

...

8 Commits

20 changed files with 2140 additions and 170 deletions
+32 -5
View File
@@ -219,7 +219,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 +316,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 +336,23 @@ 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.
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
+21 -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.
@@ -222,15 +224,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
+1165 -7
View File
File diff suppressed because it is too large Load Diff
@@ -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
@@ -101,6 +103,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 +145,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 +162,7 @@ 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];
maze32_game_command_t commands[MAZE32_GAME_COMMAND_CAPACITY];
uint8_t command_head;
uint8_t command_count;
@@ -179,6 +188,7 @@ typedef enum {
typedef struct {
uint16_t cell_index;
uint8_t passages;
uint8_t opened_passages;
} maze32_view_cell_t;
typedef struct {
+85 -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,43 @@ 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 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 +145,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 +212,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 +230,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 +274,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 +285,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 +430,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 +510,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;
@@ -460,7 +519,27 @@ 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_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;
}
@@ -589,7 +668,8 @@ 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];
view->cell_count++;
}
}
@@ -781,8 +861,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]",
index == first ? "" : ",", cell->cell_index, cell->passages,
cell->opened_passages)) {
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
}
}
+20 -1
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] = "";
+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.
+43 -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,30 @@ 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
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.
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.
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:
+86 -13
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.
@@ -92,16 +103,21 @@ are not MVP requirements.
- Connection details and the Wi-Fi settings link belong in a secondary panel,
not in the persistent status row.
- 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 +127,65 @@ 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.
The browser uses one restartable impact timer and no 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.
### 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 16 CSS pixels wide at 40-pixel scale, with flattened vegetation 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.
## 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

+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;
}
+45
View File
@@ -0,0 +1,45 @@
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);
});
+123 -9
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);
@@ -102,6 +118,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);
@@ -115,11 +138,13 @@ test("avatar control resets safely and retains accessible alternatives", () => {
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"/);
@@ -128,6 +153,56 @@ test("avatar control resets safely and retains accessible alternatives", () => {
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("frontend name suggestions randomize both words and remain bounded", () => {
const picks = [3, 4];
const suggestion = player.randomSuggestedName([], () => picks.shift());
@@ -178,21 +253,34 @@ 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("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"]);
@@ -217,12 +305,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(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 +319,31 @@ test("player assets are local, Russian, accessible, and responsive", () => {
assert.match(icons, /id="maze"/);
});
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 +367,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]);
+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-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>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

+7 -2
View File
@@ -1,5 +1,11 @@
<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>
@@ -9,7 +15,6 @@
<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.4 KiB

+8 -8
View File
@@ -9,6 +9,7 @@
</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>
@@ -34,8 +35,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 +45,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,8 +53,8 @@
<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>
@@ -63,7 +64,7 @@
<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 +73,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,7 +81,6 @@
<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>
+80 -47
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;
@@ -84,8 +85,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,46 +111,64 @@ 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; }
.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"] .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-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; }
@@ -174,18 +195,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 +222,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 +252,8 @@ 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)); }
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)); }
.welcome-screen { grid-template-columns: 1fr; padding: 28px 2px; }
.hero-art { display: none; }
.welcome-panel { padding: 22px 18px; }
@@ -240,7 +266,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; }
@@ -252,7 +278,8 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.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; }
.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; }
.connection-pill #connection-text { max-width: 145px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
@@ -269,10 +296,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,6 +312,11 @@ 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); } }
+264 -62
View File
@@ -14,15 +14,16 @@
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 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 +48,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 +148,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 +162,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 +190,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;
}
@@ -245,7 +258,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 && 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);
}
class SnapshotAssembler {
@@ -289,6 +302,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 +355,61 @@
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, 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}};
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);
state.effects.impactTimer = null;
state.effects.rateTimer = null;
state.effects.breakthroughTimer = null;
state.hedge.openedKeys.clear();
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) {
@@ -329,7 +430,8 @@
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");
}
renderWelcomeHeadline(randomLoginHeadline());
@@ -380,7 +482,7 @@
const requestId = `${type}-${sequence}`;
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 +519,76 @@
return wrapper;
}
function hedgeSymbol(edge) {
return edge.opened ? `hedge-breach-${hedgeVariant(edge, 3)}` : `hedge-intact-${hedgeVariant(edge, 6)}`;
}
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 renderMap(snapshot) {
@@ -452,33 +598,35 @@
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);
});
renderHedgeEdges(maze, snapshot.cells, snapshot.width, minX, minY);
byId("map-placeholder").hidden = true;
}
@@ -491,17 +639,16 @@
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";
@@ -512,7 +659,7 @@
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();
@@ -541,32 +688,28 @@
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);
});
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 +723,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 +758,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 +771,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;
@@ -651,12 +795,15 @@
} 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") { 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 +813,44 @@
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.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.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();
}
function resetAvatarControl() {
@@ -771,9 +945,37 @@
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(); });
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));
@@ -814,7 +1016,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, 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});
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); });