Compare commits
14 Commits
5a806fb5b8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3518f8a152 | |||
| 8ad856b931 | |||
| b6d484dbc9 | |||
| 44433aee9c | |||
| 8a37412416 | |||
| 720cd18b6e | |||
| 07eaed9c93 | |||
| 9ce44cdef7 | |||
| e10ad3acb8 | |||
| bb5bd73ebd | |||
| 8949891a6b | |||
| 2501345740 | |||
| abb3ff27b7 | |||
| 8214e7f7ed |
+53
-5
@@ -195,6 +195,16 @@ stateDiagram-v2
|
||||
During `Paused`, movement commands are rejected and move-counted effects do not
|
||||
advance. During `FinishPending`, the first exit is retained while the server
|
||||
waits long enough to determine whether the second exit creates a draw.
|
||||
An authenticated explicit player leave from any non-waiting phase, including
|
||||
`Finished`, stops the current lifecycle and returns remaining sessions to the
|
||||
waiting lobby. This is distinct from an unexpected disconnect, which preserves
|
||||
the player's reconnect reservation for 60 seconds; observer leave and player
|
||||
leave from `Lobby` revoke only the departing session.
|
||||
|
||||
The game stores a fixed two-bit owner mask for each canonical traversable
|
||||
edge. View construction exposes those masks only for authorized visible edges,
|
||||
and the browser replaces its bounded trail drawing layer from each complete
|
||||
projection.
|
||||
|
||||
## 6. Authoritative command processing
|
||||
|
||||
@@ -219,7 +229,8 @@ conceptual records include:
|
||||
|
||||
- `MazeConfig`: width, height, movement cooldown, object-density settings, and
|
||||
balance thresholds;
|
||||
- `Maze`: compact cell topology plus landmark identifiers;
|
||||
- `Maze`: compact generated cell topology plus landmark identifiers;
|
||||
- `MatchTopology`: fixed-size server-opened passage overlay for one match;
|
||||
- `PlayerState`: slot, display name, session token reference, position,
|
||||
inventory, vision effect, connection state, and last accepted move time;
|
||||
- `KeyState`: owner and either a ground position or carrier;
|
||||
@@ -315,10 +326,19 @@ The browser application has four presentation modes:
|
||||
3. player match view;
|
||||
4. observer full-map view.
|
||||
|
||||
Player controls support keyboard movement, touch swipes, and an explicit
|
||||
drop-key action. The UI renders only server-provided visible state. The observer
|
||||
view supports fitting, panning, or zooming the complete `32 x 32` map without
|
||||
changing server authority.
|
||||
Player controls support keyboard movement, avatar drag, touch swipes, an
|
||||
explicit four-button directional pad, and an explicit drop-key action. Every
|
||||
movement input uses the same bounded latest-intent path and sends only a
|
||||
cardinal intention; the browser does not predict a movement result. The UI
|
||||
renders only server-provided visible state. The observer view supports fitting,
|
||||
panning, or zooming the complete `32 x 32` map without changing server
|
||||
authority.
|
||||
|
||||
The browser renders Player 1 as yellow and Player 2 as blue. The matching key
|
||||
and exit use the owner's color as a secondary identity signal, while distinct
|
||||
SVG silhouettes and open/closed treatments identify object type and state. This
|
||||
presentation mapping is local only and does not alter authoritative game or
|
||||
projection data.
|
||||
|
||||
`docs/UI_STYLE.md` is the canonical browser presentation contract. The player
|
||||
layout supports a minimum `412 x 916` CSS-pixel viewport. Observer mode requires
|
||||
@@ -326,6 +346,34 @@ at least `1024 x 768` CSS pixels and presents a large-screen requirement message
|
||||
below that threshold. Responsive layout changes presentation only; they never
|
||||
change projection content, command validation, or server authority.
|
||||
|
||||
The ESP stores each player's bounded perseverance streak and a fixed-size
|
||||
opened-passage overlay. It alone expires streaks at 3,000 ms, opens both passage
|
||||
bits, and projects changed passages only in cells already visible to that role.
|
||||
The browser may correlate a matching server-approved impact stage with an
|
||||
already visible bush wall; it never derives or opens topology locally.
|
||||
|
||||
Browser hedge rendering derives a bounded canonical edge list solely from the
|
||||
current role projection. Its deterministic local SVG selection is presentation
|
||||
only: it cannot create maze edges, alter hit testing, retain hidden cells, or
|
||||
change authoritative movement and visibility. A player-only breakthrough
|
||||
animation may begin only when a newer projection contains both a newly opened
|
||||
visible edge and an adjacent server-approved self-cell move across that exact
|
||||
edge; it is bounded, replaceable by a newer projection, and never delays the
|
||||
received state.
|
||||
|
||||
The browser may keep one low-priority heartbeat in flight after a session is
|
||||
established. It measures complete round trip with its own monotonic clock; the
|
||||
matching acknowledgement contains only a bounded ESP-monotonic handling
|
||||
interval. The displayed communication remainder is `max(0, round trip - ESP
|
||||
handling)` and is never represented as a synchronized-clock measurement or
|
||||
pure Wi-Fi diagnosis.
|
||||
|
||||
Player projections are translated only within their received cells so the
|
||||
authoritative `self` cell aligns with the responsive local focal point. That
|
||||
point is centered horizontally and 30% down the usable portrait map region;
|
||||
compact landscape uses a left-biased unobstructed region. It changes no server
|
||||
projection, visibility, or hidden-state handling.
|
||||
|
||||
All application assets are local and precompressed. Browser startup and play
|
||||
must not depend on internet access, third-party CSS, remote fonts, icons,
|
||||
analytics, or content services. The complete compressed web payload is limited
|
||||
|
||||
@@ -109,8 +109,10 @@ approximately five minutes. There is no match time limit.
|
||||
simultaneous turns.
|
||||
- A small server-enforced minimum interval exists between accepted moves. Its
|
||||
value is configurable for playtesting.
|
||||
- Rejected movement into a wall does not change position and is not a
|
||||
successful move.
|
||||
- A server-accepted impact against the same closed interior wall from the same
|
||||
cell and direction is tracked per player. The first four impacts do not move
|
||||
the player; the fifth within three seconds of each previous impact opens the
|
||||
passage in both cells and completes that move. Perimeter walls never open.
|
||||
- Players do not block each other and may occupy the same cell.
|
||||
- A meeting occurs when both players occupy the same cell.
|
||||
- There are no attacks, health, damage, or other combat mechanics in MVP.
|
||||
@@ -214,6 +216,18 @@ Browser controls must support:
|
||||
state.
|
||||
- If the player does not reconnect within 60 seconds, the match ends in a draw.
|
||||
- Reloading a page is treated as a reconnect, not as leaving the match.
|
||||
- A confirmed explicit player exit during any non-waiting phase, including a
|
||||
finished result, revokes only that session and authoritatively returns all
|
||||
remaining participants to a fresh waiting lobby. Observer exits and player
|
||||
exits from an already waiting lobby affect only the departing session.
|
||||
|
||||
### Player trails
|
||||
|
||||
- Every successful in-maze player move is recorded authoritatively as a
|
||||
persistent passage trail for the current match. Players receive only trail
|
||||
segments inside their current fog-of-war projection; observers receive both
|
||||
complete trails. Trails clear with the match and never come from browser
|
||||
history.
|
||||
|
||||
## 11. Browser presentation
|
||||
|
||||
@@ -222,15 +236,26 @@ Browser controls must support:
|
||||
`docs/UI_STYLE.md`.
|
||||
- All player-facing UI copy, game messages, and browser errors are in Russian.
|
||||
- The UI uses a near-black background, purple panels, turquoise interactive
|
||||
accents, blue for Player 1, pink for Player 2, and plant-like green maze
|
||||
walls. Important state is distinguished by icons, labels, outlines, or
|
||||
shapes in addition to color.
|
||||
accents, yellow for Player 1, blue for Player 2, and plant-like green maze
|
||||
walls. Each player's matching key and exit use the same ownership color.
|
||||
Important state is distinguished by icons, labels, outlines, or shapes in
|
||||
addition to color; amber warnings and blue pause states use distinct
|
||||
treatments so they cannot be confused with player identity.
|
||||
- The player map consumes all space not required by fixed status and control
|
||||
panels. The player view supports a minimum `412 x 916` CSS-pixel viewport
|
||||
without page scrolling.
|
||||
- Computers support keyboard movement and on-screen arcade controls. Tablets
|
||||
support swipes and on-screen controls. Phones use directional swipes without
|
||||
on-screen directional buttons.
|
||||
- On portrait phones, the authoritative player focal point is horizontally
|
||||
centered at approximately 30% of the usable map height, above the operating
|
||||
hand zone. Compact landscape uses a stable left-biased focal point in the
|
||||
largest unobstructed map area.
|
||||
- Textual system notices use one Russian live message rail above the top bar.
|
||||
A connection-loss notice remains there until recovery; movement blocked by a
|
||||
wall and rate limiting use maze-only visual feedback and never show text.
|
||||
- Computers and tablets support keyboard, swipes, and on-screen arcade
|
||||
controls. Phones show an always-visible four-button directional pad as the
|
||||
primary touch control while retaining avatar drag and directional swipes as
|
||||
optional alternatives. The pad sends only existing orthogonal movement
|
||||
intentions through the authoritative server path.
|
||||
- An always-visible `OK` control with a key icon drops the carried key without
|
||||
confirmation and exposes the Russian accessible label `Сбросить ключ`.
|
||||
- Observer mode requires at least a `1024 x 768` CSS-pixel viewport, initially
|
||||
|
||||
@@ -10,6 +10,8 @@ extern "C" {
|
||||
#endif
|
||||
|
||||
#define MAZE32_GAME_PLAYER_COUNT 2U
|
||||
#define MAZE32_GAME_BREAKTHROUGH_HITS 5U
|
||||
#define MAZE32_GAME_BREAKTHROUGH_TIMEOUT_MS 3000U
|
||||
#define MAZE32_GAME_COMMAND_CAPACITY 16U
|
||||
#define MAZE32_GAME_KEY_COUNT 2U
|
||||
#define MAZE32_GAME_NO_KEY UINT8_MAX
|
||||
@@ -20,9 +22,11 @@ extern "C" {
|
||||
#define MAZE32_GAME_MAX_OBJECTS \
|
||||
(6U + 2U * MAZE32_MAZE_MAX_LANDMARK_PAIRS + 2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS)
|
||||
#define MAZE32_GAME_VIEW_CELLS_PER_CHUNK 12U
|
||||
#define MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS 8U
|
||||
#define MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS 5U
|
||||
#define MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK 6U
|
||||
#define MAZE32_GAME_VIEW_CHUNK_BYTES 512U
|
||||
#define MAZE32_GAME_MAX_TRAIL_EDGES (2U * MAZE32_MAZE_MAX_CELLS)
|
||||
#define MAZE32_GAME_TRAIL_BYTES ((MAZE32_GAME_MAX_TRAIL_EDGES + 3U) / 4U)
|
||||
#define MAZE32_GAME_RAM_BUDGET_BYTES (40U * 1024U)
|
||||
#define MAZE32_GAME_RUNTIME_BUDGET_MS 50U
|
||||
|
||||
@@ -101,6 +105,10 @@ typedef struct {
|
||||
uint8_t exited;
|
||||
uint8_t connected;
|
||||
uint64_t reconnect_deadline_ms;
|
||||
maze32_cell_t perseverance_cell;
|
||||
maze32_game_direction_t perseverance_direction;
|
||||
uint64_t perseverance_last_impact_ms;
|
||||
uint8_t perseverance_hits;
|
||||
} maze32_game_player_t;
|
||||
|
||||
typedef enum {
|
||||
@@ -139,6 +147,8 @@ typedef struct {
|
||||
uint8_t activated_artifact;
|
||||
uint8_t opened_exit;
|
||||
uint8_t outward_exit;
|
||||
uint8_t breakthrough_hits;
|
||||
uint8_t breakthrough_opened;
|
||||
uint8_t finished;
|
||||
uint8_t key;
|
||||
uint8_t artifact;
|
||||
@@ -154,6 +164,8 @@ typedef struct {
|
||||
maze32_game_key_t keys[MAZE32_GAME_KEY_COUNT];
|
||||
maze32_game_exit_t exits[MAZE32_GAME_PLAYER_COUNT];
|
||||
uint8_t consumed_artifacts[MAZE32_GAME_MAX_ARTIFACTS];
|
||||
uint8_t opened_passages[MAZE32_MAZE_MAX_CELLS];
|
||||
uint8_t trail_owners[MAZE32_GAME_TRAIL_BYTES];
|
||||
maze32_game_command_t commands[MAZE32_GAME_COMMAND_CAPACITY];
|
||||
uint8_t command_head;
|
||||
uint8_t command_count;
|
||||
@@ -179,6 +191,9 @@ typedef enum {
|
||||
typedef struct {
|
||||
uint16_t cell_index;
|
||||
uint8_t passages;
|
||||
uint8_t opened_passages;
|
||||
uint8_t trail_east;
|
||||
uint8_t trail_south;
|
||||
} maze32_view_cell_t;
|
||||
|
||||
typedef struct {
|
||||
|
||||
@@ -20,6 +20,19 @@ static maze32_passage_t direction_passage(maze32_game_direction_t direction) {
|
||||
return PASSAGES[direction];
|
||||
}
|
||||
|
||||
static maze32_passage_t opposite_passage(maze32_passage_t passage) {
|
||||
if (passage == MAZE32_PASSAGE_NORTH) {
|
||||
return MAZE32_PASSAGE_SOUTH;
|
||||
}
|
||||
if (passage == MAZE32_PASSAGE_EAST) {
|
||||
return MAZE32_PASSAGE_WEST;
|
||||
}
|
||||
if (passage == MAZE32_PASSAGE_SOUTH) {
|
||||
return MAZE32_PASSAGE_NORTH;
|
||||
}
|
||||
return MAZE32_PASSAGE_EAST;
|
||||
}
|
||||
|
||||
static maze32_cell_t moved_cell(maze32_cell_t cell, maze32_game_direction_t direction) {
|
||||
if (direction == MAZE32_GAME_DIRECTION_NORTH) {
|
||||
cell.y--;
|
||||
@@ -45,6 +58,73 @@ static int cells_equal(maze32_cell_t first, maze32_cell_t second) {
|
||||
return first.x == second.x && first.y == second.y;
|
||||
}
|
||||
|
||||
static uint8_t game_passages(const maze32_game_t* game, maze32_cell_t cell) {
|
||||
return (uint8_t)(game->maze->passages[cell_index(game->maze, cell)] |
|
||||
game->opened_passages[cell_index(game->maze, cell)]);
|
||||
}
|
||||
|
||||
static int game_has_passage(const maze32_game_t* game, maze32_cell_t cell,
|
||||
maze32_passage_t passage) {
|
||||
return (game_passages(game, cell) & passage) != 0U;
|
||||
}
|
||||
|
||||
static uint16_t trail_edge_index(const maze32_maze_t* maze, maze32_cell_t cell,
|
||||
maze32_passage_t passage) {
|
||||
uint16_t horizontal_count = (uint16_t)(maze->config.height * (maze->config.width - 1U));
|
||||
if (passage == MAZE32_PASSAGE_EAST) {
|
||||
return (uint16_t)(cell.y * (maze->config.width - 1U) + cell.x);
|
||||
}
|
||||
return (uint16_t)(horizontal_count + cell.y * maze->config.width + cell.x);
|
||||
}
|
||||
|
||||
static uint8_t trail_owner_mask(const maze32_game_t* game, uint16_t edge_index) {
|
||||
uint8_t byte = game->trail_owners[edge_index / 4U];
|
||||
return (uint8_t)((byte >> ((edge_index % 4U) * 2U)) & 0x03U);
|
||||
}
|
||||
|
||||
static void mark_trail_edge(maze32_game_t* game, uint8_t player_index, maze32_cell_t origin,
|
||||
maze32_game_direction_t direction) {
|
||||
maze32_cell_t canonical = origin;
|
||||
maze32_passage_t passage = direction_passage(direction);
|
||||
if (direction == MAZE32_GAME_DIRECTION_NORTH) {
|
||||
canonical.y--;
|
||||
passage = MAZE32_PASSAGE_SOUTH;
|
||||
} else if (direction == MAZE32_GAME_DIRECTION_WEST) {
|
||||
canonical.x--;
|
||||
passage = MAZE32_PASSAGE_EAST;
|
||||
}
|
||||
uint16_t edge_index = trail_edge_index(game->maze, canonical, passage);
|
||||
uint8_t shift = (uint8_t)((edge_index % 4U) * 2U);
|
||||
game->trail_owners[edge_index / 4U] |= (uint8_t)((1U << player_index) << shift);
|
||||
}
|
||||
|
||||
static void reset_perseverance(maze32_game_player_t* player) {
|
||||
player->perseverance_hits = 0U;
|
||||
player->perseverance_last_impact_ms = 0U;
|
||||
}
|
||||
|
||||
static uint8_t record_wall_impact(maze32_game_player_t* player, maze32_cell_t cell,
|
||||
maze32_game_direction_t direction, uint64_t submitted_ms) {
|
||||
if (player->perseverance_hits == 0U || !cells_equal(player->perseverance_cell, cell) ||
|
||||
player->perseverance_direction != direction ||
|
||||
submitted_ms < player->perseverance_last_impact_ms ||
|
||||
submitted_ms - player->perseverance_last_impact_ms >= MAZE32_GAME_BREAKTHROUGH_TIMEOUT_MS) {
|
||||
player->perseverance_cell = cell;
|
||||
player->perseverance_direction = direction;
|
||||
player->perseverance_hits = 1U;
|
||||
} else {
|
||||
player->perseverance_hits++;
|
||||
}
|
||||
player->perseverance_last_impact_ms = submitted_ms;
|
||||
return player->perseverance_hits;
|
||||
}
|
||||
|
||||
static void reset_all_perseverance(maze32_game_t* game) {
|
||||
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||
reset_perseverance(&game->players[player]);
|
||||
}
|
||||
}
|
||||
|
||||
static int is_visible(maze32_cell_t center, maze32_cell_t cell, uint8_t radius) {
|
||||
uint16_t dx = center.x > cell.x ? center.x - cell.x : cell.x - center.x;
|
||||
uint16_t dy = center.y > cell.y ? center.y - cell.y : cell.y - center.y;
|
||||
@@ -95,6 +175,9 @@ maze32_game_result_t maze32_game_init(maze32_game_t* game, const maze32_maze_t*
|
||||
void maze32_game_set_phase(maze32_game_t* game, maze32_game_phase_t phase) {
|
||||
if (game != NULL && phase >= MAZE32_GAME_PHASE_COUNTDOWN &&
|
||||
phase <= MAZE32_GAME_PHASE_FINISHED) {
|
||||
if (phase != MAZE32_GAME_PHASE_ACTIVE) {
|
||||
reset_all_perseverance(game);
|
||||
}
|
||||
if (phase == MAZE32_GAME_PHASE_ACTIVE &&
|
||||
(!game->players[0].connected || !game->players[1].connected)) {
|
||||
game->phase_before_pause = MAZE32_GAME_PHASE_ACTIVE;
|
||||
@@ -159,6 +242,7 @@ int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) {
|
||||
game->paused_started_ms = 0U;
|
||||
game->command_head = 0U;
|
||||
game->command_count = 0U;
|
||||
reset_all_perseverance(game);
|
||||
game->state_sequence++;
|
||||
return 1;
|
||||
}
|
||||
@@ -176,6 +260,7 @@ int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) {
|
||||
game->phase = MAZE32_GAME_PHASE_FINISHED;
|
||||
game->final_result = game->first_exited_player == 0U ? MAZE32_GAME_FINAL_PLAYER_ONE
|
||||
: MAZE32_GAME_FINAL_PLAYER_TWO;
|
||||
reset_all_perseverance(game);
|
||||
game->state_sequence++;
|
||||
return 1;
|
||||
}
|
||||
@@ -219,6 +304,7 @@ maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8
|
||||
}
|
||||
|
||||
player->connected = 0U;
|
||||
reset_perseverance(player);
|
||||
player->reconnect_deadline_ms = now_ms > UINT64_MAX - MAZE32_GAME_RECONNECT_TIMEOUT_MS
|
||||
? UINT64_MAX
|
||||
: now_ms + MAZE32_GAME_RECONNECT_TIMEOUT_MS;
|
||||
@@ -229,6 +315,7 @@ maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8
|
||||
game->paused_started_ms = now_ms;
|
||||
game->command_head = 0U;
|
||||
game->command_count = 0U;
|
||||
reset_all_perseverance(game);
|
||||
}
|
||||
game->state_sequence++;
|
||||
return MAZE32_GAME_RESULT_ACCEPTED;
|
||||
@@ -373,6 +460,7 @@ static void finish_outward_move(maze32_game_t* game, uint8_t player_index, uint6
|
||||
} else {
|
||||
game->phase = MAZE32_GAME_PHASE_FINISHED;
|
||||
game->final_result = MAZE32_GAME_FINAL_DRAW;
|
||||
reset_all_perseverance(game);
|
||||
processed->finished = 1U;
|
||||
}
|
||||
}
|
||||
@@ -452,6 +540,7 @@ maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) {
|
||||
return processed;
|
||||
}
|
||||
if (direction_leaves_map(game->maze, player->position, command.direction)) {
|
||||
reset_perseverance(player);
|
||||
if (!cells_equal(player->position, game->maze->exits[player_index]) ||
|
||||
!game->exits[player_index].open) {
|
||||
processed.result = MAZE32_GAME_RESULT_BLOCKED;
|
||||
@@ -459,12 +548,34 @@ maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) {
|
||||
}
|
||||
finish_outward_move(game, player_index, command.submitted_ms, &processed);
|
||||
} else {
|
||||
maze32_cell_t origin = player->position;
|
||||
maze32_passage_t passage = direction_passage(command.direction);
|
||||
if (!maze32_maze_has_passage(game->maze, player->position, passage)) {
|
||||
if (!game_has_passage(game, player->position, passage)) {
|
||||
processed.breakthrough_hits = record_wall_impact(
|
||||
player, player->position, command.direction, command.submitted_ms);
|
||||
if (processed.breakthrough_hits < MAZE32_GAME_BREAKTHROUGH_HITS) {
|
||||
processed.result = MAZE32_GAME_RESULT_BLOCKED;
|
||||
return processed;
|
||||
}
|
||||
maze32_cell_t destination = moved_cell(player->position, command.direction);
|
||||
if (!valid_cell(game->maze, destination)) {
|
||||
reset_perseverance(player);
|
||||
processed.result = MAZE32_GAME_RESULT_BLOCKED;
|
||||
return processed;
|
||||
}
|
||||
game->opened_passages[cell_index(game->maze, player->position)] |= passage;
|
||||
game->opened_passages[cell_index(game->maze, destination)] |= opposite_passage(passage);
|
||||
processed.breakthrough_opened = 1U;
|
||||
reset_perseverance(player);
|
||||
}
|
||||
if (game_has_passage(game, player->position, passage)) {
|
||||
reset_perseverance(player);
|
||||
} else {
|
||||
processed.result = MAZE32_GAME_RESULT_BLOCKED;
|
||||
return processed;
|
||||
}
|
||||
player->position = moved_cell(player->position, command.direction);
|
||||
mark_trail_edge(game, player_index, origin, command.direction);
|
||||
accept_player_move(player, command.submitted_ms);
|
||||
processed.moved = 1U;
|
||||
resolve_cell_entry(game, player_index, &processed);
|
||||
@@ -589,7 +700,22 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_
|
||||
if (reveal_all || is_visible(center, cell, radius)) {
|
||||
uint16_t index = cell_index(game->maze, cell);
|
||||
view->cells[view->cell_count].cell_index = index;
|
||||
view->cells[view->cell_count].passages = game->maze->passages[index];
|
||||
view->cells[view->cell_count].passages = game_passages(game, cell);
|
||||
view->cells[view->cell_count].opened_passages = game->opened_passages[index];
|
||||
if (x + 1U < game->maze->config.width) {
|
||||
maze32_cell_t east = {x + 1U, y};
|
||||
if (reveal_all || is_visible(center, east, radius)) {
|
||||
view->cells[view->cell_count].trail_east = trail_owner_mask(
|
||||
game, trail_edge_index(game->maze, cell, MAZE32_PASSAGE_EAST));
|
||||
}
|
||||
}
|
||||
if (y + 1U < game->maze->config.height) {
|
||||
maze32_cell_t south = {x, y + 1U};
|
||||
if (reveal_all || is_visible(center, south, radius)) {
|
||||
view->cells[view->cell_count].trail_south = trail_owner_mask(
|
||||
game, trail_edge_index(game->maze, cell, MAZE32_PASSAGE_SOUTH));
|
||||
}
|
||||
}
|
||||
view->cell_count++;
|
||||
}
|
||||
}
|
||||
@@ -781,8 +907,9 @@ maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view,
|
||||
}
|
||||
for (size_t index = first; index < end; ++index) {
|
||||
const maze32_view_cell_t* cell = &view->cells[index];
|
||||
if (!append_text(output, output_size, &offset, "%s[%u,%u]", index == first ? "" : ",",
|
||||
cell->cell_index, cell->passages)) {
|
||||
if (!append_text(output, output_size, &offset, "%s[%u,%u,%u,%u,%u]",
|
||||
index == first ? "" : ",", cell->cell_index, cell->passages,
|
||||
cell->opened_passages, cell->trail_east, cell->trail_south)) {
|
||||
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ maze32_lobby_result_t maze32_lobby_join_observer(maze32_lobby_t* lobby, int conn
|
||||
maze32_lobby_result_t maze32_lobby_restore(maze32_lobby_t* lobby, int connection_id,
|
||||
const char* token, maze32_session_t** session);
|
||||
void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id);
|
||||
int maze32_lobby_leave(maze32_lobby_t* lobby, int connection_id, maze32_role_t* role,
|
||||
unsigned int* player_slot);
|
||||
maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id, uint64_t now_ms);
|
||||
int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms);
|
||||
void maze32_lobby_reset_match(maze32_lobby_t* lobby);
|
||||
|
||||
@@ -22,6 +22,7 @@ typedef enum {
|
||||
MAZE32_MESSAGE_MOVE,
|
||||
MAZE32_MESSAGE_DROP_KEY,
|
||||
MAZE32_MESSAGE_RESTART,
|
||||
MAZE32_MESSAGE_LEAVE,
|
||||
MAZE32_MESSAGE_HEARTBEAT,
|
||||
} maze32_message_type_t;
|
||||
|
||||
|
||||
@@ -167,6 +167,30 @@ void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id) {
|
||||
}
|
||||
}
|
||||
|
||||
int maze32_lobby_leave(maze32_lobby_t* lobby, int connection_id, maze32_role_t* role,
|
||||
unsigned int* player_slot) {
|
||||
if (lobby == NULL) {
|
||||
return 0;
|
||||
}
|
||||
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||
maze32_session_t* session = &lobby->sessions[index];
|
||||
if (session->role != MAZE32_ROLE_NONE && session->connected &&
|
||||
session->connection_id == connection_id) {
|
||||
if (role != NULL) {
|
||||
*role = session->role;
|
||||
}
|
||||
if (player_slot != NULL) {
|
||||
*player_slot = session->player_slot;
|
||||
}
|
||||
memset(session, 0, sizeof(*session));
|
||||
session->connection_id = -1;
|
||||
lobby->revision++;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id,
|
||||
uint64_t now_ms) {
|
||||
if (lobby == NULL || lobby->phase != MAZE32_LOBBY_WAITING) {
|
||||
|
||||
@@ -193,6 +193,9 @@ static maze32_message_type_t message_type(const char* value) {
|
||||
if (strcmp(value, "restart") == 0) {
|
||||
return MAZE32_MESSAGE_RESTART;
|
||||
}
|
||||
if (strcmp(value, "leave") == 0) {
|
||||
return MAZE32_MESSAGE_LEAVE;
|
||||
}
|
||||
if (strcmp(value, "heartbeat") == 0) {
|
||||
return MAZE32_MESSAGE_HEARTBEAT;
|
||||
}
|
||||
|
||||
@@ -722,6 +722,20 @@ static void send_game_error(maze32_gateway_t* gateway, int socket_fd, const char
|
||||
send_error(gateway, socket_fd, request_id, maze32_game_result_name(result), message);
|
||||
}
|
||||
|
||||
static void send_breakthrough_impact(maze32_gateway_t* gateway, int socket_fd,
|
||||
const char* request_id, uint8_t hits) {
|
||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
|
||||
int written = snprintf(response, sizeof(response),
|
||||
"{\"v\":1,\"type\":\"error\",\"seq\":%" PRIu32
|
||||
",\"requestId\":\"%s\",\"payload\":{\"code\":\"blocked\","
|
||||
"\"message\":\"Там стена. Очень убедительная\","
|
||||
"\"breakthroughHits\":%u}}",
|
||||
next_server_sequence(gateway), request_id, (unsigned int)hits);
|
||||
if (written >= 0 && (size_t)written < sizeof(response)) {
|
||||
enqueue(gateway, socket_fd, response);
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_t* session,
|
||||
const maze32_client_message_t* message) {
|
||||
if (session->role == MAZE32_ROLE_OBSERVER) {
|
||||
@@ -753,7 +767,12 @@ static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_
|
||||
if (finished_now) {
|
||||
complete_match(gateway);
|
||||
}
|
||||
send_game_error(gateway, session->connection_id, message->request_id, result);
|
||||
if (result == MAZE32_GAME_RESULT_BLOCKED && processed.breakthrough_hits > 0U) {
|
||||
send_breakthrough_impact(gateway, session->connection_id, message->request_id,
|
||||
processed.breakthrough_hits);
|
||||
} else {
|
||||
send_game_error(gateway, session->connection_id, message->request_id, result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
char acknowledgement[160] = "";
|
||||
@@ -929,6 +948,39 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
||||
"Сначала выберите роль или восстановите сессию");
|
||||
return;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_LEAVE) {
|
||||
maze32_role_t leaving_role = session->role;
|
||||
int cancel_match =
|
||||
leaving_role == MAZE32_ROLE_PLAYER && gateway->lobby.phase != MAZE32_LOBBY_WAITING;
|
||||
char acknowledgement[192] = "";
|
||||
size_t length = 0U;
|
||||
char prefix[96];
|
||||
int written = snprintf(prefix, sizeof(prefix),
|
||||
"{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32 ",\"requestId\":",
|
||||
next_server_sequence(gateway));
|
||||
if (written >= 0 && (size_t)written < sizeof(prefix) &&
|
||||
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix) &&
|
||||
append_json_string(acknowledgement, sizeof(acknowledgement), &length,
|
||||
message->request_id) &&
|
||||
append_text(acknowledgement, sizeof(acknowledgement), &length,
|
||||
",\"payload\":{\"command\":\"leave\"}}")) {
|
||||
enqueue(gateway, socket_fd, acknowledgement);
|
||||
}
|
||||
if (cancel_match) {
|
||||
esp_timer_stop(gateway->countdown_timer);
|
||||
esp_timer_stop(gateway->reconnect_timer);
|
||||
esp_timer_stop(gateway->finish_timer);
|
||||
initialize_game(gateway);
|
||||
maze32_lobby_reset_match(&gateway->lobby);
|
||||
}
|
||||
maze32_lobby_leave(&gateway->lobby, socket_fd, NULL, NULL);
|
||||
if (cancel_match) {
|
||||
broadcast_match_event(gateway, "reset", "{}");
|
||||
}
|
||||
broadcast_snapshot(gateway);
|
||||
drain_queue(gateway);
|
||||
return;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_START) {
|
||||
if (session->role == MAZE32_ROLE_OBSERVER) {
|
||||
ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=start", socket_fd);
|
||||
@@ -958,18 +1010,29 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
||||
return;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_HEARTBEAT) {
|
||||
int64_t heartbeat_started_us = esp_timer_get_time();
|
||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
|
||||
size_t length = 0U;
|
||||
char prefix[128];
|
||||
char handling_text[32];
|
||||
int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32,
|
||||
next_server_sequence(gateway));
|
||||
if (written >= 0 && (size_t)written < sizeof(prefix) &&
|
||||
uint64_t handling_ms = (uint64_t)(esp_timer_get_time() - heartbeat_started_us) / 1000U;
|
||||
if (handling_ms > 1000U) {
|
||||
handling_ms = 1000U;
|
||||
}
|
||||
int handling_written =
|
||||
snprintf(handling_text, sizeof(handling_text), "%" PRIu64, handling_ms);
|
||||
if (written >= 0 && (size_t)written < sizeof(prefix) && handling_written >= 0 &&
|
||||
(size_t)handling_written < sizeof(handling_text) &&
|
||||
append_text(response, sizeof(response), &length, prefix) &&
|
||||
append_text(response, sizeof(response), &length, ",\"requestId\":") &&
|
||||
append_json_string(response, sizeof(response), &length, message->request_id) &&
|
||||
append_text(response, sizeof(response), &length,
|
||||
",\"payload\":{\"command\":\"heartbeat\"}}")) {
|
||||
enqueue(gateway, socket_fd, response);
|
||||
",\"payload\":{\"command\":\"heartbeat\",\"handlingMs\":") &&
|
||||
append_text(response, sizeof(response), &length, handling_text) &&
|
||||
append_text(response, sizeof(response), &length, "}}")) {
|
||||
enqueue_with_policy(gateway, socket_fd, response, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
@@ -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.
|
||||
@@ -14,6 +14,7 @@ browser timestamp from an ESP timestamp.
|
||||
| command to painted view | browser | `move` send to next frame after matching view render |
|
||||
| handler and send | ESP | existing `/diagnostics` maxima |
|
||||
| network round trip | client | ICMP and WebSocket heartbeat samples |
|
||||
| ESP heartbeat handling | ESP | bounded acknowledgement preparation interval |
|
||||
|
||||
The accepted command acknowledgement contains the authoritative `stateSequence`.
|
||||
The browser records a sample only after the complete view with that sequence is
|
||||
@@ -38,6 +39,10 @@ rendered and the next animation frame occurs. Samples are local-only, capped at
|
||||
and send maxima, queue high-water marks, observer view chunk count, and
|
||||
ICMP/WebSocket round trips. Attribute a delay only when the matching
|
||||
interval is elevated.
|
||||
7. For the compact quality indicator, record the rounded `Отклик`, `Связь`,
|
||||
and `ESP` values after the five-second heartbeat cadence has collected a
|
||||
fresh sample. `Связь` is the non-negative round-trip remainder, not a pure
|
||||
Wi-Fi measurement.
|
||||
|
||||
## Safety checks
|
||||
|
||||
|
||||
+73
-3
@@ -12,6 +12,20 @@ locally authored `icons.svg` sprite. The deterministic asset build compresses
|
||||
each file with gzip for LittleFS. There are no remote fonts, frameworks,
|
||||
analytics, icon services, or runtime network dependencies beyond the local ESP.
|
||||
|
||||
## Gameplay icon mapping
|
||||
|
||||
`icons.svg` contains editable, local SVG symbols with a shared `64 x 64` view
|
||||
box for the reference-derived gameplay set: `player`, `key`, `exit-closed`,
|
||||
`crystal`, `exit-open`, and `spikes`. Player 1 is yellow and Player 2 is blue;
|
||||
their matching keys and exits use the same owner color. The player, key, and
|
||||
exit silhouettes remain distinct, and Player 2 uses a dashed secondary
|
||||
treatment so ownership does not depend on color alone.
|
||||
|
||||
The closed and opened arched exits show the existing locked and opened exit
|
||||
states. The crystal represents the existing vision artifact. The spike symbol
|
||||
is kept as a local catalog asset but is not shown for blocked movement; it does
|
||||
not add a trap, damage, protocol, or server-state feature.
|
||||
|
||||
## Input contract
|
||||
|
||||
Arrow keys and WASD map to `north`, `east`, `south`, and `west` move intentions.
|
||||
@@ -20,7 +34,11 @@ layouts hide the direction pad and retain swipes; tablet and desktop layouts
|
||||
show the direction pad and also accept swipes. The persistent `OK` control
|
||||
sends `drop_key` and exposes the accessible label `Сбросить ключ`.
|
||||
|
||||
The centered player avatar is also the touch movement handle. Its transparent
|
||||
On phones, an always-visible four-button directional pad is the primary touch
|
||||
control. Its inactive centre prevents diagonal input; each button sends one of
|
||||
the same four authoritative movement intentions and a held button repeats no
|
||||
faster than every 170 ms. The centered player avatar is also an optional touch
|
||||
movement handle. Its transparent
|
||||
target is 96 by 96 CSS pixels while the visible avatar can deflect at most 28
|
||||
CSS pixels. A 12-pixel dead zone and diagonal hysteresis resolve only cardinal
|
||||
intentions. Drag displacement selects a monotonically bounded local cadence
|
||||
@@ -30,8 +48,60 @@ drag changes into one latest intention; release discards that unsent intention.
|
||||
The deflection is local input feedback only. The maze redraws only after a
|
||||
server-authoritative projection and the browser never predicts a cell change.
|
||||
For every projection, the browser translates only the received cells so the
|
||||
authoritative `self` cell stays under the centered avatar; this also keeps the
|
||||
avatar off a wall when the visible projection reaches a maze boundary.
|
||||
authoritative `self` cell stays under the responsive avatar focal point. On a
|
||||
portrait phone this is horizontally centered at 30% of the map height; compact
|
||||
landscape uses a stable left-biased unobstructed point away from the directional
|
||||
pad. This also keeps the avatar off a wall when the visible projection reaches
|
||||
a maze boundary, and is recalculated on resize and rotation.
|
||||
|
||||
## Feedback and message rail
|
||||
|
||||
## Compact settings
|
||||
|
||||
The persistent gear button beside the connection indicator opens one compact
|
||||
popover. It shows live connection details and Wi-Fi settings on every screen.
|
||||
During an active player match it additionally provides sound, reduced-motion,
|
||||
and optional-help controls with visible Russian on/off labels. Opening or
|
||||
closing this presentation-only menu never sends a command or changes server
|
||||
state. Escape and outside activation dismiss it; player actions also close it
|
||||
before opening their existing destination.
|
||||
|
||||
Authenticated sessions also see `Выйти из игры`. Its confirmation states that
|
||||
a player exit from any non-waiting phase, including the result screen, returns
|
||||
the remaining participants to the waiting lobby; observer exits and waiting
|
||||
lobby player exits release only the departing browser's place. The browser
|
||||
removes its token only after the correlated server acknowledgement.
|
||||
|
||||
The header response indicator takes at most one heartbeat sample every five
|
||||
seconds, holds one in-flight request, and uses a five-sample median. Good is
|
||||
at most 150 ms, delayed is 151--500 ms, and slow is above 500 ms. Hysteresis
|
||||
holds good until above 175 ms and slow until at most 450 ms; a sample is stale
|
||||
after 15 seconds. Hidden documents and closed sockets do not probe.
|
||||
|
||||
The browser stores the direction for each outstanding move request. A matching
|
||||
server `blocked` rejection may include only that player's approved breakthrough
|
||||
stage, which bends the already rendered bush wall for at most 340 ms without
|
||||
blocked-move text. A received opened-passage projection paired with an adjacent
|
||||
authoritative player move may show one 480 ms hedge-parting dash and four
|
||||
bounded decorative accents before settling at the server-approved destination.
|
||||
Opened-passage bits render a permanent trampled bush gap with a 60% clear
|
||||
centre. Effects have restartable timers and clear on a newer projection, pause,
|
||||
result, reset, disconnect, screen change, and control reset. Reduced motion is
|
||||
static and settles directly at the received destination.
|
||||
|
||||
The map draws only a bounded canonical hedge-edge layer generated from current
|
||||
received cells. It hashes each visible edge deterministically to choose the
|
||||
approved local SVG variant, so the browser neither retains unseen geometry nor
|
||||
decides topology. The layer is decorative and cannot receive input.
|
||||
|
||||
Each complete projection also replaces one bounded SVG trail layer. It draws
|
||||
only server-authorized visible east/south passage masks: yellow and blue use
|
||||
different dotted rhythms, and a shared passage retains both patterns.
|
||||
|
||||
All remaining Russian system messages use one `role=status` live message rail
|
||||
above the application top bar. One connection-loss message has highest priority
|
||||
and remains until a successful recovery; other messages deduplicate, replace
|
||||
lower-priority content, and dismiss after 2.6 seconds.
|
||||
|
||||
Commands use the version-one protocol envelope and contain intention data only:
|
||||
|
||||
|
||||
+11
-2
@@ -39,6 +39,10 @@ is only echoed for correlation; it grants no authority.
|
||||
- `restart`: `{}`. Only connected Player 1 may reset a finished match, and both
|
||||
players must be connected. Sessions are preserved while a new authoritative
|
||||
maze and game state are created.
|
||||
- `leave`: `{}`. Revokes only the authenticated sending session. A player
|
||||
leaving any non-waiting match phase, including a finished result, cancels
|
||||
that match and returns remaining sessions to the waiting lobby; observer and
|
||||
waiting-lobby departures release only that session.
|
||||
- `heartbeat`: `{}` after a session has been joined or restored.
|
||||
|
||||
## Server messages
|
||||
@@ -52,7 +56,9 @@ is only echoed for correlation; it grants no authority.
|
||||
- `active`: the exact authoritative activation instant.
|
||||
- `view`: a bounded chunk of a role-specific authoritative game projection.
|
||||
Observer chunks contain the complete maze and both player records, including
|
||||
their connection state; player chunks retain the fog-of-war boundary.
|
||||
their connection state; player chunks retain the fog-of-war boundary. Each
|
||||
projected cell carries compact Player 1 and Player 2 trail ownership masks
|
||||
for its east and south edges only when both edge endpoints are authorized.
|
||||
- `pause`: identifies the disconnected player slot and its monotonic reconnect
|
||||
deadline.
|
||||
- `resume`: reports that both players are attached and authoritative gameplay
|
||||
@@ -60,7 +66,10 @@ is only echoed for correlation; it grants no authority.
|
||||
- `match_result`: reports the terminal winner or draw.
|
||||
- `reset`: reports the authoritative return from a finished match to the
|
||||
waiting lobby.
|
||||
- `ack`: successful heartbeat correlation.
|
||||
- `ack`: successful heartbeat correlation. A heartbeat acknowledgement has
|
||||
`payload.command` set to `heartbeat` and a bounded integer
|
||||
`payload.handlingMs` measured only by the ESP monotonic clock while it
|
||||
prepares that acknowledgement.
|
||||
- `error`: a stable English `code` and Russian player-facing `message`.
|
||||
|
||||
An observer session can receive snapshots but cannot issue `start`, `move`, or
|
||||
|
||||
+113
-15
@@ -30,10 +30,11 @@ roles must remain stable:
|
||||
- black or near-black for the application and map background;
|
||||
- purple for panels and elevated surfaces;
|
||||
- turquoise for interactive accents;
|
||||
- blue for Player 1 and pink for Player 2 across every screen, map object, and
|
||||
message;
|
||||
- green for success, yellow for warnings, red for errors, blue for pause, and
|
||||
orange for disconnection;
|
||||
- yellow for Player 1 and blue for Player 2 across every screen, map object,
|
||||
and message; each player's matching key and exit use the same ownership
|
||||
color;
|
||||
- green for success, amber for warnings, red for errors, periwinkle for pause,
|
||||
and orange for disconnection;
|
||||
- green vegetation, bushes, and plants for maze walls.
|
||||
|
||||
When two semantic roles use related colors, icons, labels, outlines, or shapes
|
||||
@@ -41,6 +42,12 @@ must keep them unambiguous. Important player and object information must not be
|
||||
communicated by color alone. Key objects use an additional label, outline, or
|
||||
marker.
|
||||
|
||||
The gameplay icon family is locally authored SVG artwork derived from the
|
||||
approved running-player, key, arched-exit, crystal, opened-exit, and spike
|
||||
reference silhouettes. The spike icon remains a catalog asset only; blocked
|
||||
movement uses the maze feedback defined below and never represents a trap or a
|
||||
new gameplay mechanic.
|
||||
|
||||
WCAG AA contrast certification and dedicated color-vision-deficiency palettes
|
||||
are not MVP requirements.
|
||||
|
||||
@@ -64,7 +71,10 @@ are not MVP requirements.
|
||||
- Visible keyboard-focus outlines are not required for the MVP.
|
||||
- On computers, movement works from the keyboard and from an on-screen
|
||||
directional control cluster fixed in the lower-right corner.
|
||||
- On touch devices, the centered player avatar is the movement handle. Its
|
||||
- On touch devices, an always-visible four-button directional pad in the lower
|
||||
right is the primary movement control. Its centre is inactive, each button
|
||||
is at least 48 by 48 CSS pixels, and it sends only one cardinal intention.
|
||||
The centered player avatar remains an optional movement handle. Its
|
||||
transparent touch target is at least 96 by 96 CSS pixels and its visible
|
||||
character remains unobscured. During a drag, it deflects no more than 28 CSS
|
||||
pixels from center and returns exactly to center on release, cancellation,
|
||||
@@ -72,8 +82,9 @@ are not MVP requirements.
|
||||
- The avatar drag has a 12 CSS-pixel dead zone and directional hysteresis. Its
|
||||
bounded repeat cadence changes monotonically from 360 ms at a small
|
||||
intentional displacement to 170 ms at the target edge; it never exceeds the
|
||||
server's 150 ms movement cooldown. Touch swipes remain supported. Phones do
|
||||
not show a directional-button cluster.
|
||||
server's 150 ms movement cooldown. The pad's hold repeat is 170 ms. Touch
|
||||
swipes remain supported. The pad, avatar drag, swipe, and keyboard inputs
|
||||
share one bounded latest-intent path.
|
||||
- The always-visible key-drop control is labelled `OK`, includes a key icon,
|
||||
and is positioned closer to the upper edge of the screen than the
|
||||
directional controls. Dropping does not require confirmation.
|
||||
@@ -89,19 +100,40 @@ are not MVP requirements.
|
||||
- The player identity is represented by a distinct character shape, not `P1`
|
||||
or `P2`. Object, blocked-move, pause, and result feedback must pair a shape
|
||||
or icon with its color.
|
||||
- Connection details and the Wi-Fi settings link belong in a secondary panel,
|
||||
not in the persistent status row.
|
||||
- A single gear button beside the persistent Wi-Fi connection indicator opens
|
||||
the secondary settings popover. It contains connection details and Wi-Fi
|
||||
settings on every screen, plus the player-only sound, reduced-motion, and
|
||||
help actions during an active player match. The popover is constrained to
|
||||
safe areas, closes on outside activation or Escape, and never contains
|
||||
primary game, lobby, observer, or result actions.
|
||||
- The settings popover shows `Выйти из игры` only for an authenticated session.
|
||||
It opens a confirmation modal; a player match, including a finished result,
|
||||
warns that it returns everyone to the waiting lobby, while observer and
|
||||
waiting-lobby departures explain that only this browser's place is released.
|
||||
- Trails are dotted passage-center lines beneath maze objects and hedges: Player
|
||||
1 uses the established yellow dot rhythm, Player 2 uses a distinct blue
|
||||
rhythm, and shared passages draw both rhythms on one centerline.
|
||||
- A compact response indicator between the connection state and gear shows a
|
||||
rounded total response time or an explicit unknown, stale, or offline state.
|
||||
Its accessible description and settings details expose `Отклик`, `Связь`,
|
||||
and `ESP`; `Связь` is a transport remainder and does not assign blame to
|
||||
Wi-Fi, the ESP, or a player.
|
||||
- Countdown, pause, and result states appear as modal overlays above the map.
|
||||
- Errors and rejected actions appear as contextual tooltips or notices near
|
||||
the relevant control or status area.
|
||||
- Textual system notices use one live message rail above the top bar and player
|
||||
status area. It shows one message at a time: connection loss has highest
|
||||
priority and persists until recovery; all other messages replace lower
|
||||
priority content, deduplicate by event, and dismiss after 2.6 seconds.
|
||||
- The lobby uses separate blocks for players, observers, and match actions.
|
||||
- Network provisioning uses the same colors, type, controls, and humor as the
|
||||
game application.
|
||||
|
||||
## 7. Player map presentation
|
||||
|
||||
- Keep the player near the center of the map viewport except where maze
|
||||
boundaries prevent it.
|
||||
- Keep the portrait player horizontally centered at approximately 30% of the
|
||||
usable map height, above the directional-pad hand zone. In compact landscape,
|
||||
use the stable left-biased focal point in the largest unobstructed map area.
|
||||
Recalculate it after resize and rotation without moving the authoritative
|
||||
player away from its received `self` cell.
|
||||
- Render unknown space as a solid background. Never infer, retain, animate, or
|
||||
decorate maze geometry outside the server-provided visible projection.
|
||||
- Compute cell size from the current server projection and available map area.
|
||||
@@ -111,8 +143,74 @@ are not MVP requirements.
|
||||
- Player zoom and pan controls are not part of the MVP.
|
||||
- Use different colors plus labels, outlines, shapes, or icons to distinguish
|
||||
player ownership and object types.
|
||||
- Give immediate visual feedback when movement is rejected by a wall without
|
||||
revealing hidden state.
|
||||
- Server-approved breakthrough impacts one through four bend the visible bush
|
||||
wall with four increasing restrained stages and no text or spike notice. A
|
||||
fifth accepted move is reflected by a permanent textured trampled-bush gap.
|
||||
Only a received opened-edge projection paired with an adjacent authoritative
|
||||
player position may start one 480 ms hedge-parting, player dash, and fixed
|
||||
four-accent leaf-and-sparkle sequence. It uses one restartable timer and no
|
||||
unbounded generated particles.
|
||||
`rate_limited` still darkens and blurs only the maze for at most 520 ms.
|
||||
- Reduced-motion mode uses static deformation and the same trampled-gap state;
|
||||
it never shows travel or moving accents.
|
||||
|
||||
### Hedge art review contract
|
||||
|
||||
- Hedge samples are direct top-down vectors for review only; they are not
|
||||
runtime assets in this milestone. A segment occupies one cell edge and uses
|
||||
a centered, 10 CSS-pixel-wide foliage band at 40 CSS pixels per cell, scaling
|
||||
proportionally at 34 and 46 pixels.
|
||||
- The family uses deep shadow `#173d2a`, branch `#765033`, foliage dark
|
||||
`#296443`, foliage mid `#4f9b55`, foliage light `#8cc968`, and pale leaf
|
||||
highlight `#c7e68a`. A dark outline and branch fork preserve silhouette in
|
||||
grayscale; color is supplementary depth, not the only boundary cue.
|
||||
- Each intact symbol combines three or more named leaf clusters with visible
|
||||
twig forks, a restrained shadow, and an irregular outer silhouette. Straight
|
||||
symbols tile end-to-end without double borders; corner, crossing, and end
|
||||
symbols share their edge centre and may rotate or mirror without directional
|
||||
lighting.
|
||||
- Impact stages one through four retain the same wall centre while increasingly
|
||||
compressing foliage, bending twigs, and shedding a fixed small number of
|
||||
leaves. The breakthrough and trampled symbols leave a clear centred passage
|
||||
at least 24 CSS pixels wide at 40-pixel scale, with two separated foliage
|
||||
clusters, bent branches, and restrained ground debris on both sides. They
|
||||
never imply movement beyond that one authoritative edge.
|
||||
- Review sheets show the source symbols on neutral and dark-cell backgrounds
|
||||
at 34, 40, and 46 CSS pixels. They contain no coordinates, objects,
|
||||
ownership, hidden topology, animation, or runtime dependencies.
|
||||
|
||||
### Runtime hedge rendering
|
||||
|
||||
- The local `/hedges.svg` sprite is derived from the approved review source.
|
||||
It contains only reusable runtime symbols and no scripts, raster data, or
|
||||
external dependencies. The review sheet in `docs/` is never requested by a
|
||||
browser page.
|
||||
- A map uses one non-interactive hedge-edge element for each canonical closed
|
||||
or server-opened edge in its current projection. Closed shared edges are
|
||||
owned by their north or west cell; east and south edges are rendered only at
|
||||
the current projection boundary. This bounds a full 32 by 32 map to 2,112
|
||||
hedge elements and avoids duplicated foliage.
|
||||
- A stable integer hash of the canonical edge index and orientation selects one
|
||||
of the six approved intact variants. Opened edges select one of the three
|
||||
approved breach variants. The same projection edge always receives the same
|
||||
art without random state, retained exploration, or a protocol field.
|
||||
- Gameplay readability takes priority over foliage density. Hedge foliage is
|
||||
at most 20% of one cell, and the center line of every horizontal or vertical
|
||||
symbol must coincide with its authoritative cell edge. Rotation must preserve
|
||||
that center line instead of shifting a vertical hedge through cell centers.
|
||||
Every runtime SVG instance uses the approved `160 x 40` view box scaled to
|
||||
exactly one cell edge; source-coordinate artwork must never overflow into
|
||||
unrelated cells. Cell interiors remain visually quiet and continuous so
|
||||
passages read as open floor rather than as a grid of highlighted rectangles.
|
||||
- Hedge edges are decorative, have `pointer-events: none`, sit below map
|
||||
objects and players, and may overhang an edge by at most 15% of a cell. A
|
||||
projection replacement removes all obsolete edge nodes. One restartable
|
||||
breakthrough timer may briefly show the approved breakthrough frame; reduced
|
||||
motion settles immediately on the permanent breach. A player-only transient
|
||||
runner replaces the focal avatar for at most 480 ms after a matching opened
|
||||
edge and adjacent authoritative move; it has exactly four non-interactive
|
||||
accents and is cleared on any newer projection, pause, disconnect, result,
|
||||
reset, screen change, or reduced-motion mode.
|
||||
|
||||
## 8. Responsive behavior
|
||||
|
||||
|
||||
@@ -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 |
@@ -324,12 +324,48 @@ static void test_default_map_chunk_bounds(void) {
|
||||
(unsigned int)chunks, (unsigned int)largest_chunk);
|
||||
}
|
||||
|
||||
static void test_authoritative_trails_and_visibility(void) {
|
||||
maze32_maze_t maze;
|
||||
open_test_grid(&maze);
|
||||
maze.starts[1] = (maze32_cell_t){2U, 1U};
|
||||
maze32_game_t game = initialized_game(&maze);
|
||||
maze32_game_set_phase(&game, MAZE32_GAME_PHASE_ACTIVE);
|
||||
uint32_t sequence = 0U;
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST,
|
||||
200U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_WEST,
|
||||
200U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
|
||||
maze32_view_t observer;
|
||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &observer) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
uint16_t origin = index_of(1U, 1U);
|
||||
int found = 0;
|
||||
for (uint16_t index = 0U; index < observer.cell_count; ++index) {
|
||||
if (observer.cells[index].cell_index == origin) {
|
||||
assert(observer.cells[index].trail_east == 3U);
|
||||
found = 1;
|
||||
}
|
||||
}
|
||||
assert(found);
|
||||
maze32_view_t player;
|
||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &player) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
for (uint16_t index = 0U; index < player.cell_count; ++index) {
|
||||
assert(player.cells[index].trail_east <= 3U && player.cells[index].trail_south <= 3U);
|
||||
}
|
||||
puts("authoritative trail ownership and visibility: PASS");
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_movement_cooldown_and_ordering();
|
||||
test_bounded_queue();
|
||||
test_shared_cell_meeting();
|
||||
test_visibility_and_serialization();
|
||||
test_default_map_chunk_bounds();
|
||||
test_authoritative_trails_and_visibility();
|
||||
puts("game core tests: PASS");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -39,8 +39,11 @@ static void test_protocol_schema(void) {
|
||||
const char* restart = "{\"v\":1,\"type\":\"restart\",\"seq\":5,\"payload\":{}}";
|
||||
assert(maze32_protocol_parse(restart, strlen(restart), &message) == MAZE32_PROTOCOL_OK);
|
||||
assert(message.type == MAZE32_MESSAGE_RESTART);
|
||||
const char* leave = "{\"v\":1,\"type\":\"leave\",\"seq\":6,\"payload\":{}}";
|
||||
assert(maze32_protocol_parse(leave, strlen(leave), &message) == MAZE32_PROTOCOL_OK);
|
||||
assert(message.type == MAZE32_MESSAGE_LEAVE);
|
||||
const char* diagonal =
|
||||
"{\"v\":1,\"type\":\"move\",\"seq\":6,\"payload\":{\"direction\":\"northwest\"}}";
|
||||
"{\"v\":1,\"type\":\"move\",\"seq\":7,\"payload\":{\"direction\":\"northwest\"}}";
|
||||
assert(maze32_protocol_parse(diagonal, strlen(diagonal), &message) ==
|
||||
MAZE32_PROTOCOL_INVALID_PAYLOAD);
|
||||
|
||||
@@ -129,9 +132,52 @@ static void test_lobby_limits_restoration_and_authority(void) {
|
||||
puts("lobby repeated-match reset: PASS (128 cycles)");
|
||||
}
|
||||
|
||||
static void test_explicit_leave_releases_only_departing_session(void) {
|
||||
maze32_lobby_t lobby;
|
||||
maze32_lobby_init(&lobby);
|
||||
maze32_session_t* player_1 = NULL;
|
||||
maze32_session_t* player_2 = NULL;
|
||||
maze32_session_t* observer = NULL;
|
||||
assert(maze32_lobby_join_player(&lobby, 10, "Первый", TOKEN_1, &player_1) ==
|
||||
MAZE32_LOBBY_OK);
|
||||
assert(maze32_lobby_join_player(&lobby, 11, "Второй", TOKEN_2, &player_2) ==
|
||||
MAZE32_LOBBY_OK);
|
||||
assert(maze32_lobby_join_observer(&lobby, 20, OBSERVER_TOKENS[0], &observer) ==
|
||||
MAZE32_LOBBY_OK);
|
||||
|
||||
maze32_role_t role = MAZE32_ROLE_NONE;
|
||||
unsigned int player_slot = 0U;
|
||||
assert(maze32_lobby_leave(&lobby, 10, &role, &player_slot));
|
||||
assert(role == MAZE32_ROLE_PLAYER && player_slot == 1U);
|
||||
assert(maze32_lobby_session_for_connection(&lobby, 10) == NULL);
|
||||
assert(maze32_lobby_session_for_connection(&lobby, 11) == player_2);
|
||||
assert(maze32_lobby_session_for_connection(&lobby, 20) == observer);
|
||||
assert(maze32_lobby_restore(&lobby, 30, TOKEN_1, &player_1) == MAZE32_LOBBY_INVALID_TOKEN);
|
||||
assert(maze32_lobby_join_player(&lobby, 30, "Новый первый", OBSERVER_TOKENS[1], &player_1) ==
|
||||
MAZE32_LOBBY_OK);
|
||||
assert(player_1->player_slot == 1U);
|
||||
assert(!maze32_lobby_leave(&lobby, 10, NULL, NULL));
|
||||
|
||||
assert(maze32_lobby_leave(&lobby, 11, &role, &player_slot));
|
||||
assert(role == MAZE32_ROLE_PLAYER && player_slot == 2U);
|
||||
assert(maze32_lobby_session_for_connection(&lobby, 30) == player_1);
|
||||
assert(maze32_lobby_session_for_connection(&lobby, 20) == observer);
|
||||
assert(maze32_lobby_restore(&lobby, 31, TOKEN_2, &player_2) == MAZE32_LOBBY_INVALID_TOKEN);
|
||||
assert(maze32_lobby_join_player(&lobby, 31, "Новый второй", OBSERVER_TOKENS[2], &player_2) ==
|
||||
MAZE32_LOBBY_OK);
|
||||
assert(player_2->player_slot == 2U);
|
||||
|
||||
assert(maze32_lobby_leave(&lobby, 20, &role, &player_slot));
|
||||
assert(role == MAZE32_ROLE_OBSERVER && player_slot == 0U);
|
||||
assert(maze32_lobby_session_for_connection(&lobby, 30) == player_1);
|
||||
assert(maze32_lobby_session_for_connection(&lobby, 31) == player_2);
|
||||
puts("explicit leave session revocation and slot reuse: PASS");
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_protocol_schema();
|
||||
test_lobby_limits_restoration_and_authority();
|
||||
test_explicit_leave_releases_only_departing_session();
|
||||
puts("lobby and protocol tests: PASS");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const zlib = require("node:zlib");
|
||||
|
||||
const samplePath = path.join(__dirname, "..", "..", "docs", "hedge-sample-library.svg");
|
||||
const sample = fs.readFileSync(samplePath, "utf8");
|
||||
const runtimePath = path.join(__dirname, "..", "..", "web", "hedges.svg");
|
||||
const runtime = fs.readFileSync(runtimePath, "utf8");
|
||||
|
||||
test("hedge review sheet is local editable SVG with complete stable symbols", () => {
|
||||
assert.match(sample, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
|
||||
assert.doesNotMatch(sample, /<(?:script|image|foreignObject)\b/i);
|
||||
assert.doesNotMatch(sample, /(?:href="https?:|url\(|data:|@import|<style\b)/i);
|
||||
assert.doesNotMatch(sample, /(?:sodipodi:|inkscape:|metadata\b)/i);
|
||||
for (const identifier of [
|
||||
"leaf-cluster-a", "leaf-cluster-b", "twig-fork", "hedge-base",
|
||||
"hedge-intact-1", "hedge-intact-2", "hedge-intact-3", "hedge-intact-4",
|
||||
"hedge-intact-5", "hedge-intact-6", "hedge-corner", "hedge-crossing",
|
||||
"hedge-end", "hedge-impact-1", "hedge-impact-2", "hedge-impact-3",
|
||||
"hedge-impact-4", "hedge-breakthrough", "hedge-breach-1",
|
||||
"hedge-breach-2", "hedge-breach-3"
|
||||
]) {
|
||||
assert.match(sample, new RegExp(`id="${identifier}"`));
|
||||
}
|
||||
assert.ok((sample.match(/<use href=/g) || []).length >= 20);
|
||||
assert.ok(zlib.gzipSync(sample, {level: 9}).length < sample.length);
|
||||
});
|
||||
|
||||
test("runtime hedge sprite remains local and contains approved runtime symbols", () => {
|
||||
assert.match(runtime, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
|
||||
assert.doesNotMatch(runtime, /<(?:script|image|foreignObject|metadata)\b/i);
|
||||
assert.doesNotMatch(runtime, /(?:href="https?:|url\(|data:|@import|<style\b)/i);
|
||||
for (const identifier of [
|
||||
"hedge-intact-1", "hedge-intact-2", "hedge-intact-3", "hedge-intact-4",
|
||||
"hedge-intact-5", "hedge-intact-6", "hedge-impact-1", "hedge-impact-2",
|
||||
"hedge-impact-3", "hedge-impact-4", "hedge-breakthrough", "hedge-breach-1",
|
||||
"hedge-breach-2", "hedge-breach-3"
|
||||
]) {
|
||||
assert.match(runtime, new RegExp(`id="${identifier}"`));
|
||||
assert.match(sample, new RegExp(`id="${identifier}"`));
|
||||
}
|
||||
assert.ok(zlib.gzipSync(runtime, {level: 9}).length < 4096);
|
||||
});
|
||||
|
||||
test("permanent hedge breaches leave a 60 percent clear centre", () => {
|
||||
for (const identifier of ["hedge-breach-1", "hedge-breach-2", "hedge-breach-3"]) {
|
||||
const symbol = runtime.match(new RegExp(`<g id="${identifier}">([\\s\\S]*?)</g>`))[1];
|
||||
assert.match(symbol, /fill="#11131a" d="M32 0h96v40H32Z"/);
|
||||
}
|
||||
});
|
||||
+267
-12
@@ -63,6 +63,22 @@ test("latest movement intent coalesces while one command is in flight", () => {
|
||||
assert.equal(flow.latest, null);
|
||||
});
|
||||
|
||||
test("directional pad repeat remains slower than the authoritative cooldown", () => {
|
||||
assert.ok(player.DIRECTIONAL_PAD_REPEAT_MS > 150);
|
||||
const dispatched = [];
|
||||
const flow = new player.LatestIntentFlow((direction, element) => {
|
||||
dispatched.push({direction, element});
|
||||
return "move-1";
|
||||
});
|
||||
const button = {id: "north"};
|
||||
flow.request("north", button);
|
||||
flow.request("east", {id: "east"});
|
||||
assert.deepEqual(flow.latest, {direction: "east", element: {id: "east"}});
|
||||
flow.release();
|
||||
assert.equal(flow.latest, null);
|
||||
assert.deepEqual(dispatched, [{direction: "north", element: button}]);
|
||||
});
|
||||
|
||||
test("latency correlation is bounded and only completes matching authoritative views", () => {
|
||||
let clock = 0;
|
||||
const tracker = new player.LatencyTracker(() => clock);
|
||||
@@ -87,6 +103,29 @@ test("latency correlation is bounded and only completes matching authoritative v
|
||||
assert.equal(tracker.pointerSnapshot().length, player.LATENCY_SAMPLE_LIMIT);
|
||||
});
|
||||
|
||||
test("connection quality uses one correlated heartbeat and bounded local smoothing", () => {
|
||||
let clock = 0;
|
||||
const quality = new player.ConnectionQuality(() => clock);
|
||||
assert.equal(quality.begin("heartbeat-1"), true);
|
||||
assert.equal(quality.begin("heartbeat-2"), false);
|
||||
clock = 85;
|
||||
assert.equal(quality.acknowledge("wrong", 2), false);
|
||||
assert.equal(quality.acknowledge("heartbeat-1", 2), true);
|
||||
assert.deepEqual(quality.latest, {totalMs: 85, communicationMs: 83, handlingMs: 2, received: 85});
|
||||
for (let index = 0; index < player.QUALITY_SAMPLE_LIMIT + 2; index++) {
|
||||
clock += 10;
|
||||
assert.equal(quality.begin(`heartbeat-${index + 2}`), true);
|
||||
clock += 20;
|
||||
assert.equal(quality.acknowledge(`heartbeat-${index + 2}`, 1), true);
|
||||
}
|
||||
assert.equal(quality.samples.length, player.QUALITY_SAMPLE_LIMIT);
|
||||
assert.equal(quality.acknowledge("heartbeat-99", 1001), false);
|
||||
clock += player.HEARTBEAT_STALE_MS + 1;
|
||||
assert.equal(quality.status(), "stale");
|
||||
quality.reset();
|
||||
assert.equal(quality.status(), "unknown");
|
||||
});
|
||||
|
||||
test("player map cells enlarge small projections without exceeding a readable bound", () => {
|
||||
const sevenBySeven = player.playerCellSize(396, 730, 7, 7);
|
||||
const fourByFour = player.playerCellSize(396, 730, 4, 4);
|
||||
@@ -102,6 +141,13 @@ test("authoritative self cell remains below the centered avatar at map edges", (
|
||||
assert.deepEqual(player.playerMapOffset(-1, 7, 0, 0, 7, 7, 46), {x: 0, y: 0});
|
||||
});
|
||||
|
||||
test("responsive player focal point stays above phone controls and composes with map offsets", () => {
|
||||
assert.deepEqual(player.playerFocalPoint(400, 800), {x: 200, y: 240});
|
||||
assert.deepEqual(player.playerFocalPoint(900, 400), {x: 378, y: 180});
|
||||
assert.deepEqual(player.playerFocalMapOffset(24, 7, 1, 1, 5, 5, 46, 400, 800), {x: 0, y: -160});
|
||||
assert.deepEqual(player.playerFocalMapOffset(0, 7, 0, 0, 7, 7, 46, 400, 800), {x: 138, y: -22});
|
||||
});
|
||||
|
||||
test("onboarding has bounded, replayable steps", () => {
|
||||
assert.equal(player.ONBOARDING_STEPS.length, 8);
|
||||
assert.equal(player.nextOnboardingStep(0), 1);
|
||||
@@ -109,25 +155,112 @@ test("onboarding has bounded, replayable steps", () => {
|
||||
assert.equal(player.nextOnboardingStep(7), null);
|
||||
});
|
||||
|
||||
test("countdown completion leaves help closed until the player requests it", () => {
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
const activeView = script.match(/function renderSnapshot\(snapshot\) \{([\s\S]*?)\n \}/)[1];
|
||||
|
||||
assert.match(activeView, /setScreen\("match"\)/);
|
||||
assert.match(activeView, /setDirectionalPadEnabled\(snapshot\.phase === "active"/);
|
||||
assert.match(activeView, /else hideModal\(\);/);
|
||||
assert.doesNotMatch(activeView, /showOnboarding\(0\)/);
|
||||
assert.match(script, /byId\("settings-help"\)\.addEventListener\("click", \(\) => \{ closeSettings\(false\); showOnboarding\(0\); \}\)/);
|
||||
assert.match(script, /byId\("onboarding-skip"\)\.addEventListener\("click", hideOnboarding\)/);
|
||||
assert.match(script, /if \(next == null\) hideOnboarding\(\)/);
|
||||
});
|
||||
|
||||
test("avatar control resets safely and retains accessible alternatives", () => {
|
||||
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
assert.match(script, /addEventListener\("pointerup", stopAvatarControl\)/);
|
||||
assert.match(script, /addEventListener\("pointercancel", stopAvatarControl\)/);
|
||||
assert.match(script, /addEventListener\("lostpointercapture", stopAvatarControl\)/);
|
||||
assert.match(script, /addEventListener\("blur", resetAvatarControl\)/);
|
||||
assert.match(script, /connectionState !== "online"\) resetAvatarControl\(\)/);
|
||||
assert.match(script, /snapshot\.phase === "paused"\) \{ resetAvatarControl\(\)/);
|
||||
assert.match(script, /addEventListener\("blur", resetPlayerControls\)/);
|
||||
assert.match(script, /connectionState === "offline"\) \{ resetPlayerControls\(\);/);
|
||||
assert.match(script, /snapshot\.phase === "paused"\) \{ resetPlayerControls\(\)/);
|
||||
assert.match(script, /state\.avatar\.startX = event\.clientX/);
|
||||
assert.match(script, /event\.clientX - state\.avatar\.startX/);
|
||||
assert.match(script, /windowObject\.addEventListener\("resize", refreshPlayerFocalPoint\)/);
|
||||
assert.match(script, /windowObject\.addEventListener\("orientationchange", refreshPlayerFocalPoint\)/);
|
||||
assert.match(script, /setMuted\(!state\.muted\)/);
|
||||
assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/);
|
||||
assert.match(html, /id="avatar-control"/);
|
||||
assert.match(html, /aria-label="Тянуть героя для движения"/);
|
||||
assert.match(script, /onboarding-replay/);
|
||||
assert.match(script, /settings-help/);
|
||||
assert.match(html, /id="onboarding-dots"[\s\S]*?<span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span>/);
|
||||
});
|
||||
|
||||
test("playful feedback uses bounded maze-only effects and a top message rail", () => {
|
||||
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
assert.match(html, /<main id="app">\s*<div class="message-rail" id="notice" role="status" aria-live="polite" aria-atomic="true" hidden><\/div>\s*<header/);
|
||||
assert.doesNotMatch(html, /wall-bump-text/);
|
||||
assert.match(css, /\.maze\.impact-north/);
|
||||
assert.match(css, /\.maze\.rate-limited \{ filter: brightness\(\.48\) blur\(2px\)/);
|
||||
assert.match(css, /\[data-reduced-motion="true"\] \.maze\[class\*="impact-"\] \{ animation: none;/);
|
||||
assert.match(css, /\[data-reduced-motion="true"\] \.maze\.rate-limited \{ transition: none;/);
|
||||
assert.match(css, /\.message-rail \{ position: fixed; top:/);
|
||||
assert.match(script, /moveDirections: new Map\(\)/);
|
||||
assert.match(script, /if \(message\.payload\.code === "blocked"\) \{ showImpact\(direction, message\.payload\.breakthroughHits\); feedback\(false\); return; \}/);
|
||||
assert.match(css, /\.cell\.bush-impact/);
|
||||
assert.match(css, /\.hedge-layer/);
|
||||
assert.match(css, /\.hedge-edge \{[^}]*height: calc\(var\(--cell\) \* \.2\)/);
|
||||
assert.match(css, /\.hedge-edge\.hedge-vertical \{[^}]*left: calc\(var\(--hedge-x\) \* var\(--cell\) - var\(--cell\) \* \.5\);[^}]*top: calc\(var\(--hedge-y\) \* var\(--cell\) \+ var\(--cell\) \* \.4\)/);
|
||||
assert.match(css, /\.observer-maze \.hedge-edge\.hedge-vertical \{[^}]*top: calc\(var\(--hedge-y\) \* var\(--cell\) \+ var\(--cell\) \* \.5 - 1px\)/);
|
||||
assert.match(css, /\.cell::after \{[^}]*background: #1c1b27;[^}]*opacity: \.55/);
|
||||
assert.match(script, /projectedHedgeEdges/);
|
||||
assert.match(script, /svg\.setAttribute\("viewBox", "0 0 160 40"\)/);
|
||||
assert.match(script, /svg\.setAttribute\("preserveAspectRatio", "none"\)/);
|
||||
assert.match(script, /\/hedges\.svg#/);
|
||||
assert.match(script, /if \(message\.payload\.code === "rate_limited"\) \{ showRateLimit\(\); feedback\(false\); return; \}/);
|
||||
assert.doesNotMatch(script, /Там стена\. Очень убедительная\.|Слишком быстро! Сервер просит немного меньше турбо\./);
|
||||
});
|
||||
|
||||
test("mobile directional pad is explicit, bounded, and resettable", () => {
|
||||
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
for (const label of ["Двигаться вверх", "Двигаться вниз", "Двигаться влево", "Двигаться вправо"]) {
|
||||
assert.match(html, new RegExp(`aria-label="${label}"`));
|
||||
}
|
||||
assert.match(html, /class="dpad-center" aria-hidden="true"/);
|
||||
assert.match(html, /data-direction="north"[^>]*disabled/);
|
||||
assert.match(css, /\.dpad \{[^}]*grid-template-rows: repeat\(3, 58px\)/);
|
||||
assert.match(css, /\.dpad button:disabled/);
|
||||
assert.match(css, /\.dpad button\.active/);
|
||||
assert.match(css, /\.dpad button\.pending/);
|
||||
assert.match(css, /\.dpad \{[^}]*touch-action: none/);
|
||||
assert.match(css, /\.dpad button \{[^}]*touch-action: none/);
|
||||
assert.match(css, /grid-template-columns: repeat\(3, 62px\)/);
|
||||
assert.match(script, /function resetDirectionalPad\(\)/);
|
||||
assert.match(script, /addEventListener\("pointercancel", stopDirectionalPad\)/);
|
||||
assert.match(script, /addEventListener\("lostpointercapture", stopDirectionalPad\)/);
|
||||
assert.match(script, /windowObject\.addEventListener\("blur", resetPlayerControls\)/);
|
||||
assert.match(script, /state\.moveFlow\.request\(direction, element\)/);
|
||||
});
|
||||
|
||||
test("mobile welcome and lobby screens scroll while an active match remains fixed", () => {
|
||||
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
const mobileRules = css.slice(
|
||||
css.indexOf("@media (max-width: 720px)"),
|
||||
css.indexOf("@media (min-width: 721px)")
|
||||
);
|
||||
|
||||
assert.doesNotMatch(mobileRules, /html, body \{[^}]*overflow: hidden/);
|
||||
assert.doesNotMatch(mobileRules, /^ #app \{[^}]*height: 100dvh[^}]*overflow: hidden/m);
|
||||
assert.match(mobileRules, /body\.match-active \{ height: 100%; overflow: hidden; \}/);
|
||||
assert.match(mobileRules, /body\.match-active #app \{ height: 100dvh; min-height: 0; overflow: hidden; \}/);
|
||||
assert.match(mobileRules, /#app \{ padding: max\(8px, env\(safe-area-inset-top\)\).*max\(8px, env\(safe-area-inset-bottom\)\)/);
|
||||
assert.match(css, /\.match-screen \{[^}]*overflow: hidden/);
|
||||
assert.match(css, /\.map-shell \{[^}]*touch-action: none/);
|
||||
assert.match(script, /documentObject\.body\.classList\.toggle\("match-active", name === "match"\)/);
|
||||
|
||||
for (const viewport of [[412, 915], [412, 916], [320, 568], [568, 320]]) {
|
||||
assert.ok(viewport[0] > 0 && viewport[1] > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("frontend name suggestions randomize both words and remain bounded", () => {
|
||||
const picks = [3, 4];
|
||||
const suggestion = player.randomSuggestedName([], () => picks.shift());
|
||||
@@ -178,29 +311,59 @@ test("command envelopes contain intentions rather than client-owned state", () =
|
||||
|
||||
test("chunk assembly replaces projection cells without explored-map memory", () => {
|
||||
const assembler = new player.SnapshotAssembler();
|
||||
const second = {v: 1, type: "view", seq: 20, payload: {role: "player1", width: 8, height: 8, chunk: 1, chunks: 2, cells: [[10, 15]], objects: []}};
|
||||
const first = {v: 1, type: "view", seq: 20, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 9, inventory: null, visionMoves: 0, hint: "none", chunk: 0, chunks: 2, cells: [[9, 15]], objects: [["key", 0, 0, 10]]}};
|
||||
const second = {v: 1, type: "view", seq: 20, payload: {role: "player1", width: 8, height: 8, chunk: 1, chunks: 2, cells: [[10, 15, 0]], objects: []}};
|
||||
const first = {v: 1, type: "view", seq: 20, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 9, inventory: null, visionMoves: 0, hint: "none", chunk: 0, chunks: 2, cells: [[9, 15, 2]], objects: [["key", 0, 0, 10]]}};
|
||||
assert.equal(assembler.accept(second), null);
|
||||
const complete = assembler.accept(first);
|
||||
assert.deepEqual(player.projectedCellIndices(complete), [9, 10]);
|
||||
|
||||
const replacement = assembler.accept({v: 1, type: "view", seq: 21, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 18, inventory: 0, visionMoves: 9, hint: "ne", chunk: 0, chunks: 1, cells: [[18, 15]], objects: []}});
|
||||
const replacement = assembler.accept({v: 1, type: "view", seq: 21, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 18, inventory: 0, visionMoves: 9, hint: "ne", chunk: 0, chunks: 1, cells: [[18, 15, 0]], objects: []}});
|
||||
assert.deepEqual(player.projectedCellIndices(replacement), [18]);
|
||||
assert.equal(replacement.cells.some(cell => cell[0] === 9 || cell[0] === 10), false);
|
||||
assert.equal(assembler.accept({v: 1, type: "view", seq: 22, payload: {chunk: 0, chunks: player.MAX_VIEW_CHUNKS + 1, cells: [], objects: []}}), null);
|
||||
assert.equal(player.snapshotIsValid({width: 32, height: 32, cells: [[1023, 15]], objects: []}), true);
|
||||
assert.equal(player.snapshotIsValid({width: 32, height: 32, cells: [[1023, 15, 0]], objects: []}), true);
|
||||
assert.equal(player.snapshotIsValid({width: 33, height: 32, cells: [], objects: []}), false);
|
||||
assert.equal(player.snapshotIsValid({width: 32, height: 33, cells: [], objects: []}), false);
|
||||
});
|
||||
|
||||
test("hedge edges are canonical, bounded, and deterministic", () => {
|
||||
const cells = [[0, 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]];
|
||||
const first = player.projectedHedgeEdges(cells, 2);
|
||||
const second = player.projectedHedgeEdges(cells, 2);
|
||||
assert.deepEqual(first, second);
|
||||
assert.equal(new Set(first.map(edge => edge.key)).size, first.length);
|
||||
assert.ok(first.length <= cells.length * 2 + 2 * 2);
|
||||
assert.equal(player.hedgeVariant(first[0], 6), player.hedgeVariant(first[0], 6));
|
||||
const opened = player.projectedHedgeEdges([[0, 0, 2], [1, 0, 8]], 2);
|
||||
assert.equal(opened.filter(edge => edge.opened).length, 1);
|
||||
assert.equal(player.hedgeKeyForMove(0, 2, "east"), "v:1:0");
|
||||
});
|
||||
|
||||
test("breakthrough travel is bounded and starts only from an opened authoritative edge", () => {
|
||||
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
|
||||
assert.match(script, /function breakthroughDirection\(previous, snapshot\)/);
|
||||
assert.match(script, /projectedHedgeEdges\(snapshot\.cells, snapshot\.width\)\.some\(edge => edge\.key === key && edge\.opened\)/);
|
||||
assert.match(script, /if \(direction\) showBreakthroughTravel\(direction\);\n else stopBreakthroughTravel\(\);/);
|
||||
assert.match(script, /for \(let index = 0; index < 4; index\+\+\)/);
|
||||
assert.match(script, /setTimeout\(stopBreakthroughTravel, 480\)/);
|
||||
assert.match(script, /if \(reduced\) stopBreakthroughTravel\(\);/);
|
||||
assert.match(script, /if \(name !== "match"\) stopBreakthroughTravel\(\);/);
|
||||
assert.match(css, /\.breakthrough-runner \{[^}]*pointer-events: none[^}]*480ms/);
|
||||
assert.match(css, /@keyframes breakthrough-run/);
|
||||
assert.match(css, /\[data-reduced-motion="true"\] \.breakthrough-runner/);
|
||||
});
|
||||
|
||||
test("refresh contract restores only the token and waits for server projection", () => {
|
||||
const restore = player.createEnvelope("restore", 1, "restore-1", {token: "0123456789abcdef0123456789abcdef"});
|
||||
assert.deepEqual(Object.keys(restore.payload), ["token"]);
|
||||
assert.equal("position" in restore.payload, false);
|
||||
assert.equal("inventory" in restore.payload, false);
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
assert.equal((script.match(/localStorage\.setItem/g) || []).length, 1);
|
||||
assert.equal((script.match(/localStorage\.setItem/g) || []).length, 2);
|
||||
assert.match(script, /localStorage\.setItem\(TOKEN_KEY, message\.payload\.token\)/);
|
||||
assert.match(script, /localStorage\.setItem\(NAME_KEY, byId\("player-name"\)\.value\.slice\(0, 32\)\)/);
|
||||
});
|
||||
|
||||
test("player assets are local, Russian, accessible, and responsive", () => {
|
||||
@@ -217,12 +380,13 @@ test("player assets are local, Russian, accessible, and responsive", () => {
|
||||
assert.match(css, /@media \(max-width: 720px\)/);
|
||||
assert.match(css, /@media \(min-width: 721px\) and \(max-width: 980px\)/);
|
||||
assert.match(css, /height: calc\(100dvh - 78px\)/);
|
||||
assert.match(css, /\.dpad \{ display: none; \}/);
|
||||
assert.match(css, /\.dpad \{[^}]*grid-template-columns: repeat\(3, 58px\)/);
|
||||
assert.match(html, /id="avatar-control"/);
|
||||
assert.match(html, /id="onboarding"/);
|
||||
assert.match(html, /id="mute-toggle"/);
|
||||
assert.match(html, /id="settings-mute"/);
|
||||
assert.match(css, /\.avatar-control \{[^}]*width: 96px; height: 96px/);
|
||||
assert.match(css, /\.avatar-handle \{[^}]*transition: transform/);
|
||||
assert.match(css, /\.avatar-handle \{[^}]*background: transparent/);
|
||||
assert.match(script, /resetAvatarControl/);
|
||||
assert.match(script, /playerCellSize/);
|
||||
assert.match(css, /\(hover: none\) and \(pointer: coarse\) and \(max-height: 720px\)/);
|
||||
@@ -230,6 +394,66 @@ test("player assets are local, Russian, accessible, and responsive", () => {
|
||||
assert.match(icons, /id="maze"/);
|
||||
});
|
||||
|
||||
test("one compact settings popover replaces standalone secondary controls", () => {
|
||||
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
|
||||
assert.match(html, /id="settings-toggle"[^>]*aria-controls="settings-menu"[^>]*aria-expanded="false"[^>]*aria-label="Открыть настройки"/);
|
||||
for (const identifier of ["settings-menu", "details-text", "settings-mute", "settings-motion", "settings-help", "settings-wifi"]) {
|
||||
assert.match(html, new RegExp(`id="${identifier}"`));
|
||||
}
|
||||
assert.doesNotMatch(html, /id="details-toggle"|id="details-panel"|id="mute-toggle"|id="motion-toggle"|id="onboarding-replay"/);
|
||||
assert.doesNotMatch(html, /access-controls|access-button/);
|
||||
assert.match(css, /\.settings-menu \{[^}]*max-height: calc\(100dvh/);
|
||||
assert.match(css, /\.settings-item\[aria-pressed="true"\]/);
|
||||
assert.match(script, /function openSettings\(\)/);
|
||||
assert.match(script, /function closeSettings\(restoreFocus\)/);
|
||||
assert.match(script, /event\.key === "Escape" && !byId\("settings-menu"\)\.hidden/);
|
||||
assert.match(script, /!menu\.hidden && !menu\.contains\(event\.target\) && !trigger\.contains\(event\.target\)/);
|
||||
assert.match(script, /\["settings-mute", "settings-motion", "settings-help"\]\.forEach\(id => \{ byId\(id\)\.hidden = name !== "match"; \}\)/);
|
||||
assert.match(script, /button\.textContent = muted \? "Звук: выкл" : "Звук: вкл"/);
|
||||
assert.match(script, /button\.textContent = reduced \? "Движение: выкл" : "Движение: вкл"/);
|
||||
});
|
||||
|
||||
test("connection quality stays compact and exposes an honest response split", () => {
|
||||
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
assert.match(html, /id="quality"[^>]*aria-label="Отклик неизвестен"/);
|
||||
assert.match(html, /id="quality-details"/);
|
||||
assert.match(css, /\.quality-pill \{[^}]*min-width: 48px/);
|
||||
assert.match(script, /send\("heartbeat", \{\}, null\)/);
|
||||
assert.match(script, /communicationMs: Math\.max\(0, smoothedMs - handlingMs\)/);
|
||||
assert.match(script, /message\.payload\.command === "heartbeat"/);
|
||||
assert.match(script, /documentObject\.hidden/);
|
||||
});
|
||||
|
||||
test("reference-derived SVG gameplay symbols preserve local yellow and blue ownership", () => {
|
||||
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8");
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
||||
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||
const playerUi = fs.readFileSync(path.join(project, "docs/PLAYER_UI.md"), "utf8");
|
||||
for (const iconId of ["player", "key", "exit-closed", "crystal", "exit-open", "spikes"]) {
|
||||
assert.match(icons, new RegExp(`<symbol id="${iconId}" viewBox="0 0 64 64">`));
|
||||
}
|
||||
assert.doesNotMatch(icons, /<(?:image|foreignObject)\b/i);
|
||||
assert.doesNotMatch(icons, /data:image\//i);
|
||||
assert.match(script, /icon\(variant \? "exit-open" : "exit-closed", "object object-exit"\)/);
|
||||
assert.match(script, /icon\("crystal", "object object-vision"\)/);
|
||||
assert.match(script, /icon\("player", `pawn/);
|
||||
assert.match(html, /\/icons\.svg#spikes/);
|
||||
assert.match(css, /--p1: #ffd33d/);
|
||||
assert.match(css, /--p2: #41b6ff/);
|
||||
assert.match(css, /--warning: #ffad38/);
|
||||
assert.match(css, /--pause: #9b8cff/);
|
||||
assert.match(css, /\.pawn-owner-two \{ border-style: dashed; \}/);
|
||||
assert.match(html, /Жёлтый/);
|
||||
assert.match(html, /Синий/);
|
||||
assert.match(playerUi, /`player`, `key`, `exit-closed`,\n`crystal`, `exit-open`, and `spikes`/);
|
||||
});
|
||||
|
||||
test("observer view fits large screens and bounds zoom", () => {
|
||||
assert.equal(player.observerFitScale(1024, 600, 32, 32), 2.25);
|
||||
assert.equal(player.clampObserverZoom(.01), .25);
|
||||
@@ -253,7 +477,7 @@ test("observer protocol exposes no mutation control path", () => {
|
||||
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||
const observerSection = html.match(/<section class="screen observer-screen"[\s\S]*?<\/section>/)[0];
|
||||
assert.doesNotMatch(observerSection, /data-direction|drop-key|start-match/);
|
||||
const observer = {v: 1, type: "view", seq: 30, payload: {role: "observer", phase: "active", result: "none", width: 2, height: 2, players: [{cell: 0, key: null, exited: 0, connected: 1}, {cell: 3, key: 1, exited: 0, connected: 1}], chunk: 0, chunks: 1, cells: [[0, 6], [1, 12], [2, 3], [3, 9]], objects: []}};
|
||||
const observer = {v: 1, type: "view", seq: 30, payload: {role: "observer", phase: "active", result: "none", width: 2, height: 2, players: [{cell: 0, key: null, exited: 0, connected: 1}, {cell: 3, key: 1, exited: 0, connected: 1}], chunk: 0, chunks: 1, cells: [[0, 6, 0], [1, 12, 0], [2, 3, 0], [3, 9, 0]], objects: []}};
|
||||
const snapshot = new player.SnapshotAssembler().accept(observer);
|
||||
assert.equal(snapshot.role, "observer");
|
||||
assert.deepEqual(snapshot.players.map(item => item.cell), [0, 3]);
|
||||
@@ -266,3 +490,34 @@ test("finished matches expose only an owner restart intention", () => {
|
||||
assert.match(script, /message\.type === "reset"/);
|
||||
assert.doesNotMatch(script, /restart[^\n]*(position|inventory|result):/);
|
||||
});
|
||||
|
||||
test("finished-player exit returns remaining browsers to the waiting lobby", () => {
|
||||
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
const gateway = fs.readFileSync(path.join(project, "components/maze32_web/maze32_gateway.c"), "utf8");
|
||||
const leaveHandler = gateway.match(/if \(message->type == MAZE32_MESSAGE_LEAVE\) \{[\s\S]*?if \(message->type == MAZE32_MESSAGE_START\)/)[0];
|
||||
|
||||
assert.match(html, /id="settings-leave"/);
|
||||
assert.match(html, /id="leave-confirmation"/);
|
||||
assert.match(script, /\["countdown", "active", "finish_pending", "paused", "finished"\]\.includes\(phase\)/);
|
||||
assert.match(script, /message\.type === "reset"[\s\S]*?hideModal\(\); setScreen\("lobby"\)/);
|
||||
assert.match(script, /message\.payload\.command === "leave" && message\.requestId === state\.leaveRequest/);
|
||||
assert.match(script, /windowObject\.localStorage\.removeItem\(TOKEN_KEY\)/);
|
||||
assert.match(script, /setScreen\("welcome"\)/);
|
||||
assert.match(leaveHandler, /leaving_role == MAZE32_ROLE_PLAYER &&\s*gateway->lobby\.phase != MAZE32_LOBBY_WAITING;/);
|
||||
assert.doesNotMatch(leaveHandler, /gateway->game\.phase != MAZE32_GAME_PHASE_FINISHED/);
|
||||
});
|
||||
|
||||
test("authoritative trail masks render bounded yellow, blue, and shared paths", () => {
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
||||
assert.equal(player.snapshotIsValid({width: 2, height: 2, cells: [[0, 6, 0, 3, 0]], objects: []}), true);
|
||||
assert.equal(player.snapshotIsValid({width: 2, height: 2, cells: [[0, 6, 0, 4, 0]], objects: []}), false);
|
||||
assert.match(script, /function renderTrails\(/);
|
||||
assert.match(script, /if \(owners & 1\) paths\[0\]\.push/);
|
||||
assert.match(script, /if \(owners & 2\) paths\[1\]\.push/);
|
||||
assert.match(script, /renderTrails\(maze, snapshot\.cells/);
|
||||
assert.match(css, /\.trail-layer \{[^}]*pointer-events: none/);
|
||||
assert.match(css, /\.trail-player-one \{ stroke: var\(--p1\); stroke-dasharray/);
|
||||
assert.match(css, /\.trail-player-two \{ stroke: var\(--p2\); stroke-dasharray/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 40" aria-hidden="true">
|
||||
<defs>
|
||||
<g id="leaf-cluster-a" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round"><path fill="#296443" d="M2 13C0 6 7 0 13 3c2-7 12-5 14 2 8-1 11 8 5 13 3 8-6 13-12 8-6 5-14 1-13-6-7 0-9-5-5-7Z"/><path fill="#4f9b55" d="M7 13c2-5 7-8 12-7 5 0 8 4 9 8-3 5-8 8-14 7-5 0-8-3-7-8Z"/><path fill="#c7e68a" d="M12 9c3-3 8-3 10 0-2 3-7 4-10 0Z"/></g>
|
||||
<g id="leaf-cluster-b" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round"><path fill="#296443" d="M1 16C-1 8 5 2 11 4c1-7 11-7 14-1 7-3 13 5 8 11 5 6-2 14-9 11-4 7-15 4-15-3-8 2-12-4-8-6Z"/><path fill="#8cc968" d="M8 14c2-5 8-7 13-5 4 2 6 7 4 11-5 4-13 3-17-2Z"/></g>
|
||||
<g id="twig-fork" fill="none" stroke="#765033" stroke-width="2" stroke-linecap="round"><path d="M3 20 16 8 29 3M16 8l2 12M16 8 7 4"/></g>
|
||||
<g id="hedge-base"><path d="M0 11C9 2 20 4 30 9c12-8 23-7 34 1 10-5 22-5 32 1 11-7 23-6 34 1 9-5 22-4 30 4v17c-10 5-22 5-32 0-11 7-23 6-34 0-10 6-22 6-32 0-11 6-23 6-34 0-10 5-20 4-30-3Z" fill="#173d2a"/><use href="#twig-fork" x="5" y="9"/><use href="#twig-fork" x="54" y="8"/><use href="#twig-fork" x="105" y="9"/><use href="#leaf-cluster-a" x="1" y="5"/><use href="#leaf-cluster-b" x="27" y="4"/><use href="#leaf-cluster-a" x="54" y="6"/><use href="#leaf-cluster-b" x="80" y="4"/><use href="#leaf-cluster-a" x="107" y="5"/><use href="#leaf-cluster-b" x="132" y="5"/></g>
|
||||
<g id="hedge-intact-1"><use href="#hedge-base"/></g>
|
||||
<g id="hedge-intact-2"><use href="#hedge-base"/><path fill="#8cc968" d="M20 7l8-3 4 4-8 4Zm61 3 8-4 4 4-7 4Zm48 12 7-3 3 5-7 3Z"/></g>
|
||||
<g id="hedge-intact-3"><use href="#hedge-base"/><path fill="none" stroke="#765033" stroke-width="2" d="M38 27 49 9m36 18L73 7m47 20 13-17"/></g>
|
||||
<g id="hedge-intact-4"><use href="#hedge-base"/><path fill="#4f9b55" d="M6 27c12-5 20 6 31 0 11-6 20 6 32 0 11-6 21 5 33-1 10-5 19 3 29 0v8H6Z"/></g>
|
||||
<g id="hedge-intact-5"><use href="#hedge-base"/><path fill="#c7e68a" d="m10 17 5-4 4 3-5 4Zm42-8 5-4 4 3-5 4Zm46 11 5-4 4 3-5 4Zm38-8 5-4 4 3-5 4Z"/></g>
|
||||
<g id="hedge-intact-6"><use href="#hedge-base"/><path fill="none" stroke="#173d2a" stroke-width="2" d="M19 30 32 14l14 13m33 3 12-15 13 14m17 1 11-12 12 10"/></g>
|
||||
<g id="hedge-impact-1"><use href="#hedge-intact-1"/><path fill="#8cc968" d="m70 2 8 3-3 6-8-3Zm12 28 7 2-3 5-7-2Z"/></g>
|
||||
<g id="hedge-impact-2"><use href="#hedge-intact-1"/><path fill="#173d2a" d="M64 0h30v40H64Z" opacity=".18"/></g>
|
||||
<g id="hedge-impact-3"><use href="#hedge-intact-1" transform="skewX(-4)"/><path fill="none" stroke="#765033" stroke-width="3" d="m71 28 11-18m1 16 11-13"/></g>
|
||||
<g id="hedge-impact-4"><use href="#hedge-intact-1" transform="translate(0 4) scale(1 .86)"/><path fill="#c7e68a" d="m61 2 8 3-3 6-8-3Zm13 7 8 3-3 6-8-3Zm14-4 8 3-3 6-8-3Z"/></g>
|
||||
<g id="hedge-breakthrough"><use href="#hedge-intact-1"/><path fill="#11131a" stroke="#173d2a" stroke-width="2" d="M62 0h36v40H62Z"/><path fill="#765033" d="m60 6 10 8-4 5-11-8Zm40 12 10-8 5 5-10 9Z"/></g>
|
||||
<g id="hedge-breach-1"><use href="#hedge-intact-1"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#296443" d="M0 8c12-9 23-6 32 2v22c-11 6-22 4-32-3Zm128 2c11-8 22-11 32-2v21c-10 7-21 9-32 3Z"/><path fill="none" stroke="#765033" stroke-width="3" d="m8 30 17-15m127 15-17-15"/><path fill="#4f9b55" d="m27 30 6-5 5 7-7 4Zm99 2 7-7 6 5-5 6Z"/></g>
|
||||
<g id="hedge-breach-2"><use href="#hedge-intact-3"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#173d2a" d="M0 5c14-7 25-5 32 5v22c-10 5-22 4-32-2Zm128 5c8-10 19-12 32-5v25c-11 6-22 7-32 2Z"/><path fill="none" stroke="#765033" stroke-width="4" d="M4 32 21 9m7 25L15 19m141 13-17-23m-7 25 13-15"/><path fill="#8cc968" d="m25 28 7-6 5 7-8 5Zm98 1 7-7 6 6-7 5Z"/></g>
|
||||
<g id="hedge-breach-3"><use href="#hedge-intact-5"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#4f9b55" d="M0 10c10-10 22-8 32 0v21c-12 6-23 3-32-4Zm128 0c10-8 22-10 32 0v17c-10 9-21 10-32 4Z"/><path fill="none" stroke="#765033" stroke-width="3" d="M5 31 23 12m-9 22 14-13m132-10-18-19m9 22-14-13"/><path fill="#296443" d="m29 30 7-5 4 7-7 3Zm94 3 6-8 7 5-5 7Z"/></g>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
+8
-2
@@ -1,15 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="key" viewBox="0 0 24 24"><circle cx="8" cy="9" r="4" fill="none" stroke="currentColor" stroke-width="3"/><path d="M11 12l9 9m-4-4 2-2m-5-1 2-2" fill="none" stroke="currentColor" stroke-width="3"/></symbol>
|
||||
<!-- Reference-derived gameplay symbols use a shared 64 by 64 view box and currentColor. -->
|
||||
<symbol id="player" viewBox="0 0 64 64"><path fill="currentColor" d="M37 5a9 9 0 1 1 0 18 9 9 0 0 1 0-18Zm-8 20 10-2 10 7 8-5a6 6 0 1 1 6 10l-12 8a6 6 0 0 1-7 0l-7-5-3 11 12 8a6 6 0 0 1-7 10L24 57a6 6 0 0 1-2-7l4-15-7 4-4 10a6 6 0 1 1-11-4l5-14a6 6 0 0 1 3-3l13-7a6 6 0 0 1 4 4Z"/></symbol>
|
||||
<symbol id="key" viewBox="0 0 64 64"><path fill="currentColor" fill-rule="evenodd" d="M43 5a17 17 0 0 0-14 27L5 56l3 3 5-2 3 4 5-2 3 4 6-6-4-4 7-7A17 17 0 1 0 43 5Zm0 11a6 6 0 1 1 0 12 6 6 0 0 1 0-12Z" clip-rule="evenodd"/></symbol>
|
||||
<symbol id="exit-closed" viewBox="0 0 64 64"><path fill="currentColor" d="M15 59V29C15 10 49 10 49 29v30H15Zm10-30v30h14V29c0-8-14-8-14 0Zm-17-8 6 2-2 10-7-2 3-10Zm42 2 6-2 3 10-7 2-2-10ZM7 38h7v11H7V38Zm43 0h7v11h-7V38ZM14 9l7-5 4 9-7 3-4-7Zm32-5 7 5-4 7-7-3 4-9Z"/></symbol>
|
||||
<symbol id="exit-open" viewBox="0 0 64 64"><path fill="currentColor" d="M15 59V29C15 10 49 10 49 29v30H15Zm10-30v30h14V29c0-8-14-8-14 0ZM7 38h7v11H7V38Zm43 0h7v11h-7V38ZM14 9l7-5 4 9-7 3-4-7Zm32-5 7 5-4 7-7-3 4-9Z"/><path fill="currentColor" d="M25 36h10v-7l14 12-14 12v-7H25V36Z"/></symbol>
|
||||
<symbol id="crystal" viewBox="0 0 64 64"><path fill="currentColor" fill-rule="evenodd" d="m32 5 19 20-19 34L13 25 32 5Zm0 12-8 9h16l-8-9Zm-11 17 11 17 11-17H21Z" clip-rule="evenodd"/><path fill="currentColor" d="M32 1h4v8h-4V1ZM5 29h9v5H5v-5Zm45 0h9v5h-9v-5ZM10 11l6-6 4 4-6 6-4-4Zm34-2 4-4 6 6-4 4-6-6ZM10 53l4-4 6 6-4 4-6-6Zm34 2 6-6 4 4-6 6-4-4Z"/></symbol>
|
||||
<symbol id="spikes" viewBox="0 0 64 64"><path fill="currentColor" d="m9 47 9-32 9 32H9Zm18 0 9-32 9 32H27Zm18 0 9-32 9 32H45ZM5 51h54v9H5v-9Z"/></symbol>
|
||||
<symbol id="eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="currentColor"/></symbol>
|
||||
<symbol id="arrow" viewBox="0 0 24 24"><path d="M12 2l7 9h-4v11H9V11H5l7-9Z" fill="currentColor"/></symbol>
|
||||
<symbol id="signal" viewBox="0 0 24 24"><path d="M3 9a14 14 0 0 1 18 0M6 13a9 9 0 0 1 12 0m-9 4a4 4 0 0 1 6 0" fill="none" stroke="currentColor" stroke-width="2.5"/><circle cx="12" cy="21" r="1.5" fill="currentColor"/></symbol>
|
||||
<symbol id="gear" viewBox="0 0 24 24"><path d="M10 2h4l.7 3 2.6 1.1 2.7-1.5 2 3.5-2.2 2 .1 3 2.1 1.9-2 3.5-2.7-1.5-2.6 1.1-.7 3h-4l-.7-3-2.6-1.1-2.7 1.5-2-3.5 2.1-1.9.1-3-2.2-2 2-3.5 2.7 1.5L9.3 5Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.8"/></symbol>
|
||||
<symbol id="radar" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 12 20 6" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="2" fill="currentColor"/></symbol>
|
||||
<symbol id="hourglass" viewBox="0 0 24 24"><path d="M6 3h12M6 21h12M7 4c0 5 4 5 5 8-1 3-5 3-5 8m10-16c0 5-4 5-5 8 1 3 5 3 5 8" fill="none" stroke="currentColor" stroke-width="2"/></symbol>
|
||||
<symbol id="sound" viewBox="0 0 24 24"><path d="M4 10h4l5-4v12l-5-4H4zM17 9c2 2 2 4 0 6M20 6c4 4 4 8 0 12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
|
||||
<symbol id="motion" viewBox="0 0 24 24"><path d="M4 12h6m4 0h6M8 7l5 5-5 5M16 7l-5 5 5 5" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
|
||||
<symbol id="help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M9.5 9a2.7 2.7 0 1 1 4.2 2.2c-1.4.9-1.7 1.4-1.7 2.8M12 17h.01" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
|
||||
<symbol id="hand" viewBox="0 0 24 24"><path d="M8 12V5a1.5 1.5 0 0 1 3 0v5V3.5a1.5 1.5 0 0 1 3 0V10V5a1.5 1.5 0 0 1 3 0v6V8a1.5 1.5 0 0 1 3 0v6c0 4-3 7-7 7-3 0-5-2-6-5l-1-3a1.5 1.5 0 0 1 2-1z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="exit" viewBox="0 0 24 24"><path d="M4 3h12v18H4zM16 8h4v13H8" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linejoin="round"/><path d="m11 12 5-4m-5 4 5 4m-5-4H2" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
|
||||
<symbol id="friends" viewBox="0 0 24 24"><circle cx="8" cy="8" r="3" fill="none" stroke="currentColor" stroke-width="2.5"/><circle cx="17" cy="10" r="3" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M3 21c0-4 2-7 5-7s5 3 5 7m1-1c.5-3 1.7-5 4-5 2 0 3 2 3 5" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
|
||||
<symbol id="maze" viewBox="0 0 160 112"><path d="M8 8h144v96H8V8Zm20 0v24H8m44-24v20h28v28H52v24H28v24m52-48h28V32h24v48h20M80 80v24m28-48v48" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="square"/><circle cx="20" cy="92" r="7" fill="currentColor"/><path d="m135 89 9 7-9 7" fill="none" stroke="currentColor" stroke-width="6"/></symbol>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.8 KiB |
+21
-15
@@ -9,12 +9,23 @@
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<div class="message-rail" id="notice" role="status" aria-live="polite" aria-atomic="true" hidden></div>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/" aria-label="maze32, на главную">MAZE<span>32</span></a>
|
||||
<div class="connection-pill" id="connection" data-state="connecting" role="status"><span class="signal" aria-hidden="true"></span><span id="connection-text">Ловим сигнал ESP…</span></div>
|
||||
<button class="icon-button" id="details-toggle" type="button" aria-controls="details-panel" aria-expanded="false"><svg aria-hidden="true"><use href="/icons.svg#signal"></use></svg><span class="sr-only">Открыть сведения о соединении</span></button>
|
||||
<span class="quality-pill" id="quality" data-state="unknown" role="status" aria-live="polite" aria-label="Отклик неизвестен"><svg aria-hidden="true"><use href="/icons.svg#signal"></use></svg><span id="quality-text">—</span></span>
|
||||
<button class="icon-button" id="settings-toggle" type="button" aria-controls="settings-menu" aria-expanded="false" aria-label="Открыть настройки"><svg aria-hidden="true"><use href="/icons.svg#gear"></use></svg></button>
|
||||
</header>
|
||||
<aside class="details-panel" id="details-panel" hidden><strong>Связь с коробочкой</strong><span id="details-text">Подключение ещё не готово.</span><a href="/provision">Настроить Wi‑Fi</a></aside>
|
||||
<aside class="settings-menu" id="settings-menu" hidden aria-label="Настройки">
|
||||
<strong>Настройки</strong>
|
||||
<span class="settings-details" id="details-text">Подключение ещё не готово.</span>
|
||||
<span class="settings-quality" id="quality-details">Отклик: неизвестен</span>
|
||||
<button class="settings-item" id="settings-mute" type="button" aria-pressed="false" hidden>Звук: вкл</button>
|
||||
<button class="settings-item" id="settings-motion" type="button" aria-pressed="false" hidden>Движение: вкл</button>
|
||||
<button class="settings-item" id="settings-help" type="button" hidden>Показать обучение</button>
|
||||
<a class="settings-item" id="settings-wifi" href="/provision">Настроить Wi‑Fi</a>
|
||||
<button class="settings-item settings-exit" id="settings-leave" type="button" hidden>Выйти из игры</button>
|
||||
</aside>
|
||||
|
||||
<section class="screen welcome-screen" id="welcome-screen" aria-labelledby="welcome-title">
|
||||
<div class="hero-art" aria-hidden="true"><span class="pixel-bush bush-one"></span><span class="pixel-bush bush-two"></span><svg><use href="/icons.svg#maze"></use></svg></div>
|
||||
@@ -34,8 +45,8 @@
|
||||
<div class="lobby-grid">
|
||||
<article class="panel roster-panel">
|
||||
<h2>Игроки</h2>
|
||||
<div class="player-card player-one" id="player-1"><span class="avatar">P1</span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
|
||||
<div class="player-card player-two" id="player-2"><span class="avatar">P2</span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
|
||||
<div class="player-card player-one" id="player-1"><span class="avatar" aria-label="Жёлтый игрок"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg></span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
|
||||
<div class="player-card player-two" id="player-2"><span class="avatar" aria-label="Синий игрок"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg></span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
|
||||
</article>
|
||||
<article class="panel observer-panel"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><h2>Зрительный зал</h2><p><strong id="observer-count">0</strong> / 4 эксперта по чужим ошибкам.</p></article>
|
||||
<article class="panel launch-panel"><h2>Большая красная кнопка</h2><p id="launch-help">Старт доступен Игроку 1, когда оба слота на месте.</p><button class="button primary launch-button" id="start-match" type="button" disabled>Начать 3—2—1</button></article>
|
||||
@@ -44,7 +55,7 @@
|
||||
|
||||
<section class="screen match-screen" id="match-screen" hidden aria-label="Игровой экран">
|
||||
<div class="player-status" id="player-status">
|
||||
<div class="identity" id="identity"><span class="avatar character-avatar" id="self-slot" aria-hidden="true">●</span><span><strong id="self-name">Неизвестный герой</strong><small id="match-state">Лабиринт думает</small></span></div>
|
||||
<div class="identity" id="identity"><span class="avatar character-avatar" id="self-slot" aria-hidden="true"><svg><use href="/icons.svg#player"></use></svg></span><span><strong id="self-name">Неизвестный герой</strong><small id="match-state">Лабиринт думает</small></span></div>
|
||||
<div class="stat" id="inventory-stat"><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><span><small>Ключ</small><strong id="inventory">Пусто</strong></span></div>
|
||||
<div class="stat" id="vision-stat"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><span><small>Суперзрение</small><strong id="vision">Нет</strong></span></div>
|
||||
<div class="stat compass-stat" id="compass-stat"><svg id="compass-arrow" aria-hidden="true"><use href="/icons.svg#arrow"></use></svg><span><small>Компас</small><strong id="compass">Молчит</strong></span></div>
|
||||
@@ -52,18 +63,13 @@
|
||||
<div class="map-shell" id="map-shell" tabindex="0" aria-label="Лабиринт. Используйте стрелки, WASD или свайпы для движения">
|
||||
<div class="unknown-message" id="map-placeholder"><svg aria-hidden="true"><use href="/icons.svg#radar"></use></svg><span>Ждём разрешённый сервером кусочек карты…</span></div>
|
||||
<div class="maze" id="maze" role="img" aria-label="Видимая часть лабиринта"></div>
|
||||
<button class="avatar-control" id="avatar-control" type="button" aria-label="Тянуть героя для движения"><span class="avatar-handle" id="avatar-handle" aria-hidden="true"><span id="avatar-handle-symbol">●</span><svg><use href="/icons.svg#arrow"></use></svg></span></button>
|
||||
<div class="wall-bump" id="wall-bump" role="status" aria-live="polite"></div>
|
||||
<button class="avatar-control" id="avatar-control" type="button" aria-label="Тянуть героя для движения"><span class="avatar-handle" id="avatar-handle" aria-hidden="true"><svg class="avatar-player-icon"><use href="/icons.svg#player"></use></svg><svg class="avatar-direction-icon"><use href="/icons.svg#arrow"></use></svg></span></button>
|
||||
<div class="wall-bump" id="wall-bump" aria-hidden="true"><svg hidden><use href="/icons.svg#spikes"></use></svg></div>
|
||||
</div>
|
||||
<div class="child-controls" aria-label="Игровые действия">
|
||||
<button class="drop-key" id="drop-key" type="button" aria-label="Сбросить ключ" disabled><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><strong>OK</strong><span>бросить</span></button>
|
||||
</div>
|
||||
<div class="access-controls" aria-label="Настройки игры">
|
||||
<button class="access-button" id="mute-toggle" type="button" aria-label="Выключить звук"><svg aria-hidden="true"><use href="/icons.svg#sound"></use></svg></button>
|
||||
<button class="access-button" id="motion-toggle" type="button" aria-label="Уменьшить движение"><svg aria-hidden="true"><use href="/icons.svg#motion"></use></svg></button>
|
||||
<button class="access-button" id="onboarding-replay" type="button" aria-label="Показать обучение"><svg aria-hidden="true"><use href="/icons.svg#help"></use></svg></button>
|
||||
</div>
|
||||
<nav class="dpad" aria-label="Управление движением"><button type="button" data-direction="north" aria-label="Двигаться вверх">▲</button><button type="button" data-direction="west" aria-label="Двигаться влево">◀</button><span aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо">▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз">▼</button></nav>
|
||||
<nav class="dpad" aria-label="Управление движением"><button type="button" data-direction="north" aria-label="Двигаться вверх" disabled>▲</button><button type="button" data-direction="west" aria-label="Двигаться влево" disabled>◀</button><span class="dpad-center" aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо" disabled>▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз" disabled>▼</button></nav>
|
||||
<section class="onboarding" id="onboarding" hidden aria-label="Обучение игре">
|
||||
<div class="onboarding-card" id="onboarding-card"><svg class="onboarding-icon" aria-hidden="true"><use id="onboarding-icon-use" href="/icons.svg#hand"></use></svg><h2 id="onboarding-title">Потяни кружок</h2><p id="onboarding-text">Кружок ведёт героя по лабиринту.</p><div class="onboarding-dots" id="onboarding-dots" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div><button id="onboarding-next" type="button" aria-label="Следующий шаг обучения">Дальше</button><button id="onboarding-skip" type="button" aria-label="Пропустить обучение">Пропустить</button></div>
|
||||
</section>
|
||||
@@ -72,7 +78,7 @@
|
||||
<section class="screen observer-screen" id="observer-screen" hidden aria-labelledby="observer-title">
|
||||
<div class="observer-toolbar">
|
||||
<div><p class="eyebrow">Режим наблюдателя · только чтение</p><h1 id="observer-title">Весь куст целиком</h1></div>
|
||||
<div class="observer-players" aria-live="polite"><span class="player-one">P1 <strong id="observer-p1">готов</strong></span><span class="player-two">P2 <strong id="observer-p2">готов</strong></span><span id="observer-phase">Ждём старта</span></div>
|
||||
<div class="observer-players" aria-live="polite"><span class="player-one"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg> Жёлтый <strong id="observer-p1">готов</strong></span><span class="player-two"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg> Синий <strong id="observer-p2">готов</strong></span><span id="observer-phase">Ждём старта</span></div>
|
||||
<div class="zoom-controls" aria-label="Масштаб карты"><button id="zoom-out" type="button" aria-label="Уменьшить масштаб">−</button><button id="zoom-reset" type="button">Вся карта</button><button id="zoom-in" type="button" aria-label="Увеличить масштаб">+</button></div>
|
||||
</div>
|
||||
<div class="observer-requirement" id="observer-requirement" role="status"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><strong>Нужен экран побольше</strong><span>Зрительный зал открывается от 1024 × 768 пикселей.</span></div>
|
||||
@@ -80,10 +86,10 @@
|
||||
<div class="observer-maze" id="observer-maze" role="img" aria-label="Полная карта лабиринта"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="notice" id="notice" role="status" aria-live="polite">Сервер решает всё. Мы отвечаем за драматичное мерцание.</div>
|
||||
</main>
|
||||
|
||||
<div class="modal" id="modal" hidden role="dialog" aria-modal="true" aria-labelledby="modal-title"><div class="modal-card"><svg id="modal-icon" aria-hidden="true"><use href="/icons.svg#hourglass"></use></svg><p class="eyebrow" id="modal-kicker">Серверное объявление</p><h2 id="modal-title">Приготовились!</h2><div class="countdown" id="countdown" hidden>3</div><p id="modal-text">Один сервер — один честный момент.</p><button class="button primary" id="modal-action" type="button" hidden>Вернуться в лобби</button></div></div>
|
||||
<section class="modal" id="leave-confirmation" hidden role="dialog" aria-modal="true" aria-labelledby="leave-title"><div class="modal-card"><p class="eyebrow">Подтверждение</p><h2 id="leave-title">Выйти из игры?</h2><p id="leave-text"></p><div class="button-row"><button class="button primary" id="leave-accept" type="button">Выйти</button><button class="button" id="leave-cancel" type="button">Остаться</button></div></div></section>
|
||||
<script src="/player.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+111
-55
@@ -8,14 +8,15 @@
|
||||
--ink: #f6efff;
|
||||
--muted: #b7a8ca;
|
||||
--aqua: #43ead2;
|
||||
--p1: #58aaff;
|
||||
--p2: #ff72bd;
|
||||
--p1: #ffd33d;
|
||||
--p2: #41b6ff;
|
||||
--plant: #45b86b;
|
||||
--plant-dark: #176638;
|
||||
--success: #72e48c;
|
||||
--warning: #ffd65a;
|
||||
--warning: #ffad38;
|
||||
--vision: #37c9ff;
|
||||
--error: #ff647a;
|
||||
--pause: #5598ff;
|
||||
--pause: #9b8cff;
|
||||
--disconnect: #ff9f43;
|
||||
--cell: 40px;
|
||||
--shadow: #040207;
|
||||
@@ -35,14 +36,23 @@ button { color: inherit; }
|
||||
.brand { color: var(--ink); text-decoration: none; font-weight: 1000; font-size: clamp(1.1rem, 3vw, 1.45rem); letter-spacing: .14em; text-shadow: 3px 3px 0 #000; }
|
||||
.brand span { color: var(--aqua); }
|
||||
.connection-pill { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.quality-pill { display: flex; align-items: center; gap: 4px; min-width: 48px; color: var(--muted); font-size: .68rem; font-weight: 800; white-space: nowrap; }
|
||||
.quality-pill svg { width: 17px; height: 17px; }
|
||||
.quality-pill[data-state="good"] { color: var(--success); }
|
||||
.quality-pill[data-state="delayed"] { color: var(--warning); }
|
||||
.quality-pill[data-state="slow"], .quality-pill[data-state="offline"] { color: var(--error); }
|
||||
.signal { width: 9px; height: 9px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 10px var(--warning); }
|
||||
.connection-pill[data-state="online"] .signal { background: var(--success); box-shadow: 0 0 10px var(--success); }
|
||||
.connection-pill[data-state="offline"] .signal { background: var(--disconnect); box-shadow: 0 0 10px var(--disconnect); }
|
||||
.icon-button { width: 38px; height: 38px; padding: 8px; border: 1px solid var(--edge); background: var(--panel-deep); cursor: pointer; }
|
||||
.icon-button svg { width: 100%; height: 100%; color: var(--aqua); }
|
||||
.details-panel { position: absolute; z-index: 30; top: 60px; right: max(12px, env(safe-area-inset-right)); display: grid; gap: 6px; width: min(300px, calc(100% - 24px)); padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; }
|
||||
.details-panel span { color: var(--muted); }
|
||||
.details-panel a { color: var(--aqua); }
|
||||
.settings-menu { position: fixed; z-index: 40; top: max(58px, calc(env(safe-area-inset-top) + 58px)); right: max(12px, env(safe-area-inset-right)); display: grid; gap: 8px; width: min(300px, calc(100% - 24px)); max-height: calc(100dvh - max(70px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom))); overflow-y: auto; padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; }
|
||||
.settings-details { color: var(--muted); line-height: 1.45; }
|
||||
.settings-quality { color: var(--muted); line-height: 1.45; }
|
||||
.settings-item { min-height: 42px; display: flex; align-items: center; justify-content: space-between; border: 1px solid var(--edge); background: #0c0815; padding: 8px 10px; color: var(--ink); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
|
||||
.settings-item:hover, .settings-item:focus-visible { border-color: var(--aqua); background: #164049; }
|
||||
.settings-item[aria-pressed="true"] { border-color: var(--warning); color: var(--warning); }
|
||||
.settings-exit { border-color: var(--error); color: var(--error); }
|
||||
|
||||
.screen { min-height: calc(100dvh - 90px); animation: screen-in .28s steps(5, end); }
|
||||
.welcome-screen { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 500px); align-items: center; gap: clamp(24px, 6vw, 90px); padding: 5vh 4vw; }
|
||||
@@ -84,8 +94,10 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.player-card.player-one { border-left: 5px solid var(--p1); }
|
||||
.player-card.player-two { border-left: 5px solid var(--p2); }
|
||||
.avatar { width: 46px; height: 46px; display: grid; place-items: center; border: 2px solid currentColor; background: #0b0712; font-weight: 1000; }
|
||||
.avatar svg { width: 82%; height: 82%; }
|
||||
.player-one .avatar, .identity.player-one .avatar { color: var(--p1); }
|
||||
.player-two .avatar, .identity.player-two .avatar { color: var(--p2); }
|
||||
.player-two .avatar { border-style: dashed; }
|
||||
.player-card strong, .player-card small, .identity strong, .identity small, .stat small, .stat strong { display: block; }
|
||||
.player-card small, .identity small, .stat small { margin-top: 4px; color: var(--muted); font-size: .66rem; }
|
||||
.status-dot { width: 10px; height: 10px; background: var(--error); }
|
||||
@@ -108,54 +120,82 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.map-shell:focus-visible { border-color: var(--aqua); }
|
||||
.unknown-message { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 12px; padding: 24px; color: var(--muted); text-align: center; }
|
||||
.unknown-message svg { width: 54px; height: 54px; color: var(--aqua); animation: radar 1.8s linear infinite; }
|
||||
.maze { position: absolute; left: 50%; top: 50%; display: grid; transform: translate(-50%, -50%); }
|
||||
.cell { position: relative; width: var(--cell); height: var(--cell); background: #11131a; border: 4px solid transparent; }
|
||||
.cell.wall-n { border-top-color: var(--plant); box-shadow: inset 0 3px 0 var(--plant-dark); }
|
||||
.cell.wall-e { border-right-color: var(--plant); }
|
||||
.cell.wall-s { border-bottom-color: var(--plant); }
|
||||
.cell.wall-w { border-left-color: var(--plant); }
|
||||
.maze { position: absolute; left: 50%; top: 50%; display: grid; transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px))); }
|
||||
.cell { position: relative; width: var(--cell); height: var(--cell); background: #11131a; }
|
||||
.cell::after { content: ""; position: absolute; inset: 7px; background: #1c1b27; opacity: .55; }
|
||||
.trail-layer { position: absolute; z-index: 0; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
|
||||
.trail-path { fill: none; stroke-width: .065; stroke-linecap: round; opacity: .58; }
|
||||
.trail-player-one { stroke: var(--p1); stroke-dasharray: .06 .20; }
|
||||
.trail-player-two { stroke: var(--p2); stroke-dasharray: .03 .16; stroke-dashoffset: .08; }
|
||||
.hedge-layer { position: absolute; z-index: 1; inset: 0; pointer-events: none; overflow: visible; }
|
||||
.hedge-edge { position: absolute; width: var(--cell); height: calc(var(--cell) * .2); left: calc(var(--hedge-x) * var(--cell)); top: calc(var(--hedge-y) * var(--cell) - var(--cell) * .1); transform-origin: 50% 50%; pointer-events: none; }
|
||||
.hedge-edge svg { display: block; width: 100%; height: 100%; overflow: visible; }
|
||||
.hedge-edge.hedge-vertical { left: calc(var(--hedge-x) * var(--cell) - var(--cell) * .5); top: calc(var(--hedge-y) * var(--cell) + var(--cell) * .4); transform: rotate(90deg); }
|
||||
.cell.bush-impact { z-index: 5; animation: bush-resist .34s steps(3, end); }
|
||||
.cell.bush-stage-2 { filter: saturate(1.25); }
|
||||
.cell.bush-stage-3 { filter: brightness(1.2) saturate(1.4); }
|
||||
.cell.bush-stage-4 { filter: brightness(1.35) saturate(1.65); }
|
||||
.object, .pawn { position: absolute; z-index: 3; inset: 16%; display: grid; place-items: center; }
|
||||
.object svg { width: 100%; height: 100%; }
|
||||
.object-key { color: var(--warning); }
|
||||
.object-key::after { content: "●"; position: absolute; right: -5px; bottom: -6px; padding: 1px 3px; background: #090711; border: 1px solid currentColor; font-size: 8px; font-weight: 900; }
|
||||
.object-key[data-owner="two"]::after { content: "◆"; }
|
||||
.object-exit { inset: 8%; border: 3px double currentColor; background: #0b0911; }
|
||||
.object svg, .pawn svg { width: 100%; height: 100%; }
|
||||
.object-key { color: var(--p1); }
|
||||
.object-key[data-owner="two"] { color: var(--p2); }
|
||||
.object-key::after { content: ""; position: absolute; right: -4px; bottom: -4px; width: 8px; height: 8px; border: 2px solid currentColor; border-radius: 50%; background: #090711; }
|
||||
.object-key[data-owner="two"]::after { border-radius: 0; transform: rotate(45deg); }
|
||||
.object-exit { inset: 8%; background: #0b0911; }
|
||||
.object-exit[data-owner="one"] { color: var(--p1); }
|
||||
.object-exit[data-owner="two"] { color: var(--p2); transform: rotate(45deg); }
|
||||
.object-exit[data-owner="two"] { color: var(--p2); }
|
||||
.object-exit[data-owner="two"]::after { content: ""; position: absolute; inset: 6%; border: 2px dashed currentColor; }
|
||||
.object-exit[data-open="1"] { background: #72e48c35; animation: exit-open .8s steps(2, end) infinite; }
|
||||
.object-compass { color: var(--aqua); border: 2px dashed currentColor; border-radius: 50%; }
|
||||
.object-landmark { color: var(--plant); font-size: 1.15rem; text-shadow: 2px 2px #000; }
|
||||
.object-vision { color: var(--warning); border-radius: 50%; background: #ffd65a30; box-shadow: 0 0 10px var(--warning); animation: artifact 1.2s steps(4, end) infinite; }
|
||||
.pawn { inset: 12%; border: 3px solid currentColor; background: #0b0712; font-size: .62rem; font-weight: 1000; box-shadow: 3px 3px 0 #000; animation: pawn-in .16s steps(2, end); }
|
||||
.object-vision { color: var(--vision); border-radius: 50%; background: #37c9ff30; box-shadow: 0 0 10px var(--vision); animation: artifact 1.2s steps(4, end) infinite; }
|
||||
.pawn { inset: 12%; padding: 5%; border: 3px solid currentColor; background: #0b0712; box-shadow: 3px 3px 0 #000; animation: pawn-in .16s steps(2, end); }
|
||||
.pawn-self { color: var(--p1); }
|
||||
.match-screen.player-two-mode .pawn-self { color: var(--p2); }
|
||||
.pawn-opponent { color: var(--p2); transform: rotate(45deg); }
|
||||
.pawn-opponent { color: var(--p2); }
|
||||
.match-screen.player-two-mode .pawn-opponent { color: var(--p1); }
|
||||
.pawn-opponent span { transform: rotate(-45deg); }
|
||||
.wall-bump { position: absolute; left: 50%; bottom: 18px; z-index: 6; min-height: 28px; transform: translateX(-50%); padding: 6px 10px; color: var(--error); background: #260b12dd; opacity: 0; }
|
||||
.wall-bump.show { animation: bump .8s steps(3, end); }
|
||||
.wall-bump::before { content: "▌"; display: inline-block; margin-right: 6px; font-size: 1.35em; font-weight: 1000; }
|
||||
.pawn-owner-two { border-style: dashed; }
|
||||
.wall-bump { position: absolute; inset: 0; z-index: 6; border: 0 solid transparent; pointer-events: none; }
|
||||
.wall-bump.show { border-color: var(--error); }
|
||||
.maze.impact-north { animation: impact-north .28s steps(3, end); }
|
||||
.maze.impact-east { animation: impact-east .28s steps(3, end); }
|
||||
.maze.impact-south { animation: impact-south .28s steps(3, end); }
|
||||
.maze.impact-west { animation: impact-west .28s steps(3, end); }
|
||||
.maze.rate-limited { filter: brightness(.48) blur(2px); transition: filter .12s linear; }
|
||||
[data-reduced-motion="true"] .maze[class*="impact-"] { animation: none; }
|
||||
[data-reduced-motion="true"] .maze.rate-limited { transition: none; }
|
||||
[data-reduced-motion="true"] .breakthrough-runner, [data-reduced-motion="true"] .breakthrough-accent { display: none; }
|
||||
[data-reduced-motion="true"] .wall-bump.show { border-width: 4px; }
|
||||
[data-reduced-motion="true"] .wall-bump.impact-north { border-top-color: var(--error); }
|
||||
[data-reduced-motion="true"] .wall-bump.impact-east { border-right-color: var(--error); }
|
||||
[data-reduced-motion="true"] .wall-bump.impact-south { border-bottom-color: var(--error); }
|
||||
[data-reduced-motion="true"] .wall-bump.impact-west { border-left-color: var(--error); }
|
||||
|
||||
.avatar-control { position: absolute; z-index: 8; left: 50%; top: 50%; width: 96px; height: 96px; margin: -48px; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: grab; touch-action: none; user-select: none; }
|
||||
.avatar-control { position: absolute; z-index: 8; left: var(--player-focal-x, 50%); top: var(--player-focal-y, 50%); width: 96px; height: 96px; margin: -48px; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: grab; touch-action: none; user-select: none; }
|
||||
.avatar-control:active, .avatar-control.active { cursor: grabbing; }
|
||||
.avatar-handle { position: absolute; left: 50%; top: 50%; width: 52px; height: 52px; display: grid; place-items: center; border: 3px solid currentColor; border-radius: 50%; background: #0b0712; color: var(--p1); box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead236; font-size: 1.25rem; font-weight: 1000; transform: translate(-50%, -50%); transition: transform .12s steps(3, end); }
|
||||
.avatar-handle { position: absolute; left: 50%; top: 50%; width: 52px; height: 52px; display: grid; place-items: center; border: 3px solid currentColor; border-radius: 50%; background: transparent; color: var(--p1); box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead236; font-size: 1.25rem; font-weight: 1000; transform: translate(-50%, -50%); transition: transform .12s steps(3, end); }
|
||||
.player-two-mode .avatar-handle { color: var(--p2); }
|
||||
.avatar-control.active .avatar-handle { transition: none; }
|
||||
.avatar-handle svg { position: absolute; width: 19px; height: 19px; right: -13px; top: -13px; display: none; padding: 2px; border: 1px solid currentColor; background: #08050b; color: var(--aqua); }
|
||||
.avatar-control[data-direction] .avatar-handle svg { display: block; }
|
||||
.avatar-control[data-direction="north"] .avatar-handle svg { transform: rotate(0deg); }
|
||||
.avatar-control[data-direction="east"] .avatar-handle svg { transform: rotate(90deg); }
|
||||
.avatar-control[data-direction="south"] .avatar-handle svg { transform: rotate(180deg); }
|
||||
.avatar-control[data-direction="west"] .avatar-handle svg { transform: rotate(270deg); }
|
||||
.avatar-control.breakthrough-travelling { opacity: 0; }
|
||||
.breakthrough-runner { position: absolute; z-index: 9; left: var(--player-focal-x, 50%); top: var(--player-focal-y, 50%); width: 52px; height: 52px; display: grid; place-items: center; color: var(--p1); pointer-events: none; animation: breakthrough-run 480ms steps(6, end) forwards; }
|
||||
.breakthrough-runner.player-two { color: var(--p2); }
|
||||
.breakthrough-runner svg { width: 100%; height: 100%; padding: 5%; border: 3px solid currentColor; background: #0b0712; box-shadow: 3px 3px 0 #000; }
|
||||
.breakthrough-accent { position: absolute; width: 8px; height: 8px; border: 2px solid #c7e68a; background: var(--plant); box-shadow: 1px 1px 0 #173d2a; animation: breakthrough-accent 480ms steps(5, end) forwards; }
|
||||
.accent-1 { left: 4px; top: 6px; }
|
||||
.accent-2 { right: 2px; top: 12px; background: var(--aqua); }
|
||||
.accent-3 { left: 12px; bottom: 1px; background: #8cc968; }
|
||||
.accent-4 { right: 9px; bottom: 5px; background: var(--warning); }
|
||||
.avatar-player-icon { width: 74%; height: 74%; }
|
||||
.avatar-direction-icon { position: absolute; width: 19px; height: 19px; right: -13px; top: -13px; display: none; padding: 2px; border: 1px solid currentColor; background: #08050b; color: var(--aqua); }
|
||||
.avatar-control[data-direction] .avatar-direction-icon { display: block; }
|
||||
.avatar-control[data-direction="north"] .avatar-direction-icon { transform: rotate(0deg); }
|
||||
.avatar-control[data-direction="east"] .avatar-direction-icon { transform: rotate(90deg); }
|
||||
.avatar-control[data-direction="south"] .avatar-direction-icon { transform: rotate(180deg); }
|
||||
.avatar-control[data-direction="west"] .avatar-direction-icon { transform: rotate(270deg); }
|
||||
.avatar-control[data-speed="fast"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 4px var(--aqua), 0 0 18px #43ead299; }
|
||||
.avatar-control[data-speed="slow"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead270; }
|
||||
.child-controls { position: absolute; z-index: 12; inset: auto 0 12px; display: flex; align-items: end; justify-content: space-between; padding: 0 16px; pointer-events: none; }
|
||||
.child-controls > * { pointer-events: auto; }
|
||||
.access-controls { position: absolute; z-index: 14; top: 92px; right: 8px; display: grid; gap: 6px; }
|
||||
.access-button { width: 42px; height: 42px; display: grid; place-items: center; border: 2px solid var(--aqua); border-radius: 50%; background: #102a30e8; color: var(--aqua); box-shadow: 3px 3px 0 #020506; }
|
||||
.access-button svg { width: 24px; height: 24px; }
|
||||
.access-button[aria-pressed="true"] { color: var(--warning); border-color: var(--warning); }
|
||||
.onboarding { position: absolute; z-index: 30; inset: 0; display: grid; place-items: center; padding: 18px; background: #08050bd9; }
|
||||
.onboarding-card { width: min(340px, 92%); padding: 20px; border: 3px solid var(--aqua); background: var(--panel-deep); box-shadow: 7px 7px 0 #020103; text-align: center; }
|
||||
.onboarding-icon { width: 104px; height: 104px; color: var(--aqua); animation: guide-hand 1.2s ease-in-out infinite; }
|
||||
@@ -174,18 +214,20 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.drop-key strong { font-size: 1.2rem; }
|
||||
.drop-key span { grid-column: 1 / -1; font-size: .56rem; text-transform: uppercase; }
|
||||
.drop-key:disabled { opacity: .48; }
|
||||
.dpad { position: fixed; z-index: 11; right: max(24px, env(safe-area-inset-right)); bottom: max(28px, env(safe-area-inset-bottom)); display: grid; grid-template-columns: repeat(3, 58px); grid-template-rows: repeat(2, 58px); gap: 5px; }
|
||||
.dpad button { border: 2px solid var(--aqua); background: #102a30df; color: var(--aqua); box-shadow: 4px 4px 0 #020506; cursor: pointer; }
|
||||
.dpad { position: fixed; z-index: 16; right: max(18px, env(safe-area-inset-right)); bottom: max(18px, env(safe-area-inset-bottom)); display: grid; grid-template-columns: repeat(3, 58px); grid-template-rows: repeat(3, 58px); gap: 5px; touch-action: none; user-select: none; -webkit-user-select: none; }
|
||||
.dpad button { min-width: 58px; min-height: 58px; border: 2px solid var(--aqua); background: #102a30df; color: var(--aqua); box-shadow: 4px 4px 0 #020506; cursor: pointer; touch-action: none; user-select: none; -webkit-user-select: none; }
|
||||
.dpad button.active, .dpad button:active { transform: translate(3px, 3px); border-style: dashed; box-shadow: 1px 1px 0 #020506; }
|
||||
.dpad button:disabled { border-color: var(--edge); background: #171027c9; color: var(--muted); opacity: .55; cursor: not-allowed; }
|
||||
.dpad button.pending { outline: 3px solid var(--warning); outline-offset: -5px; }
|
||||
.dpad button:first-child { grid-column: 2; }
|
||||
.dpad button:nth-child(2) { grid-column: 1; grid-row: 2; }
|
||||
.dpad span { grid-column: 2; grid-row: 2; display: grid; place-items: center; color: #4b3a60; }
|
||||
.dpad-center { grid-column: 2; grid-row: 2; display: grid; place-items: center; border: 2px solid #3b2951; background: #0d0915d9; color: #5a4174; pointer-events: none; }
|
||||
.dpad button:nth-child(4) { grid-column: 3; grid-row: 2; }
|
||||
.dpad button:last-child { grid-column: 2; grid-row: 3; }
|
||||
|
||||
.notice { position: fixed; z-index: 40; left: 50%; bottom: max(12px, env(safe-area-inset-bottom)); max-width: calc(100% - 24px); transform: translate(-50%, 130%); padding: 10px 14px; border-left: 4px solid var(--warning); background: #191020f2; color: var(--ink); box-shadow: 4px 4px 0 #020104; font-size: .76rem; opacity: 0; transition: .18s steps(3, end); pointer-events: none; }
|
||||
.notice.visible { opacity: 1; transform: translate(-50%, 0); }
|
||||
.notice.error { border-color: var(--error); }
|
||||
.notice.success { border-color: var(--success); }
|
||||
.message-rail { position: fixed; top: max(4px, env(safe-area-inset-top)); z-index: 45; left: 50%; width: min(620px, calc(100% - 20px)); transform: translateX(-50%); padding: 8px 14px; border: 2px solid var(--warning); background: #191020f2; color: var(--ink); box-shadow: 4px 4px 0 #020104; font-size: .76rem; text-align: center; pointer-events: none; }
|
||||
.message-rail.error { border-color: var(--error); }
|
||||
.message-rail.success { border-color: var(--success); }
|
||||
.modal { position: fixed; z-index: 50; inset: 0; display: grid; place-items: center; padding: 18px; background: #050308dc; backdrop-filter: blur(5px); }
|
||||
.modal-card { width: min(440px, 100%); padding: 30px; border: 3px solid var(--aqua); background: #130b22; text-align: center; box-shadow: 9px 9px 0 #020103; animation: modal-in .28s steps(4, end); }
|
||||
.modal-card > svg { width: 54px; height: 54px; color: var(--aqua); }
|
||||
@@ -199,21 +241,23 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.observer-players { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: .72rem; }
|
||||
.observer-players span { padding: 7px; border: 1px solid #51386e; }
|
||||
.observer-players .player-one { border-color: var(--p1); }
|
||||
.observer-players .player-two { border-color: var(--p2); }
|
||||
.observer-players .player-two { border-color: var(--p2); border-style: dashed; }
|
||||
.observer-players svg { width: 16px; height: 16px; vertical-align: middle; }
|
||||
.zoom-controls { display: flex; gap: 5px; }
|
||||
.zoom-controls button { min-width: 42px; min-height: 38px; border: 1px solid var(--aqua); background: #102a30; color: var(--aqua); cursor: pointer; }
|
||||
.observer-stage { position: absolute; inset: 92px 0 0; overflow: hidden; border: 2px solid #2f2041; background: #050408; cursor: grab; touch-action: none; user-select: none; }
|
||||
.observer-stage.dragging { cursor: grabbing; }
|
||||
.observer-maze { position: absolute; left: 50%; top: 50%; display: grid; transform-origin: center; will-change: transform; }
|
||||
.observer-maze .cell { width: 8px; height: 8px; border-width: 1px; }
|
||||
.observer-maze .cell { width: 8px; height: 8px; }
|
||||
.observer-maze .cell::after { inset: 2px; }
|
||||
.observer-maze .hedge-edge { height: 2px; top: calc(var(--hedge-y) * var(--cell) - 1px); }
|
||||
.observer-maze .hedge-edge.hedge-vertical { left: calc(var(--hedge-x) * var(--cell) - var(--cell) * .5); top: calc(var(--hedge-y) * var(--cell) + var(--cell) * .5 - 1px); }
|
||||
.observer-maze .object { inset: 5%; }
|
||||
.observer-maze .object-key::after { display: none; }
|
||||
.observer-maze .object-landmark { font-size: 6px; }
|
||||
.observer-maze .pawn { inset: -30%; z-index: 5; font-size: 5px; border-width: 1px; box-shadow: 1px 1px 0 #000; }
|
||||
.observer-maze .pawn { inset: -30%; z-index: 5; padding: 1px; border-width: 1px; box-shadow: 1px 1px 0 #000; }
|
||||
.observer-maze .pawn-player-one { color: var(--p1); }
|
||||
.observer-maze .pawn-player-two { color: var(--p2); transform: rotate(45deg); }
|
||||
.observer-maze .pawn-player-two span { transform: rotate(-45deg); }
|
||||
.observer-maze .pawn-player-two { color: var(--p2); }
|
||||
.observer-requirement { display: none; position: absolute; inset: 92px 0 0; place-content: center; justify-items: center; gap: 12px; padding: 24px; color: var(--muted); text-align: center; border: 2px solid var(--edge); background: var(--panel-deep); }
|
||||
.observer-requirement svg { width: 58px; height: 58px; color: var(--p2); }
|
||||
.observer-requirement strong { color: var(--ink); font-size: 1.2rem; text-transform: uppercase; }
|
||||
@@ -227,7 +271,9 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
:root { --cell: 46px; }
|
||||
#app { padding-left: max(8px, env(safe-area-inset-left)); padding-right: max(8px, env(safe-area-inset-right)); }
|
||||
body.match-active { height: 100%; overflow: hidden; }
|
||||
#app { padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); }
|
||||
body.match-active #app { height: 100dvh; min-height: 0; overflow: hidden; }
|
||||
.welcome-screen { grid-template-columns: 1fr; padding: 28px 2px; }
|
||||
.hero-art { display: none; }
|
||||
.welcome-panel { padding: 22px 18px; }
|
||||
@@ -240,7 +286,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.player-card { margin-top: 8px; padding: 8px; }
|
||||
.roster-panel { grid-row: auto; }
|
||||
.section-heading { align-items: start; flex-direction: column; }
|
||||
.match-screen { height: calc(100dvh - 70px); padding-top: 82px; }
|
||||
.match-screen { height: calc(100dvh - 70px); min-height: 0; padding-top: 82px; }
|
||||
.player-status { grid-template-columns: 1.25fr repeat(3, .75fr); inset: 6px 0 auto; }
|
||||
.identity, .stat { min-width: 0; min-height: 72px; padding: 5px; flex-direction: column; justify-content: center; text-align: center; gap: 1px; }
|
||||
.identity .avatar { flex-basis: 34px; width: 34px; height: 34px; font-size: 1.4rem; }
|
||||
@@ -251,9 +297,11 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.map-shell { inset: 82px 0 0; }
|
||||
.child-controls { bottom: max(14px, env(safe-area-inset-bottom)); padding: 0 10px; }
|
||||
.drop-key { position: relative; top: auto; right: auto; bottom: auto; width: 78px; height: 78px; }
|
||||
.access-controls { top: 88px; right: 8px; }
|
||||
.dpad { display: none; }
|
||||
.settings-menu { top: max(52px, calc(env(safe-area-inset-top) + 52px)); }
|
||||
.dpad { right: max(12px, env(safe-area-inset-right)); bottom: max(12px, env(safe-area-inset-bottom)); grid-template-columns: repeat(3, 62px); grid-template-rows: repeat(3, 62px); gap: 5px; }
|
||||
.dpad button { min-width: 62px; min-height: 62px; font-size: 1.3rem; }
|
||||
.connection-pill { font-size: .58rem; }
|
||||
.quality-pill { min-width: 40px; font-size: .6rem; }
|
||||
.connection-pill #connection-text { max-width: 145px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
}
|
||||
|
||||
@@ -269,10 +317,11 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.identity, .stat { min-height: 55px; }
|
||||
.map-shell { inset: 64px 0 0; }
|
||||
.drop-key { top: 76px; right: 14px; width: 70px; height: 70px; }
|
||||
.dpad { right: max(12px, env(safe-area-inset-right)); bottom: max(8px, env(safe-area-inset-bottom)); grid-template-columns: repeat(3, 52px); grid-template-rows: repeat(3, 52px); }
|
||||
.dpad button { min-width: 52px; min-height: 52px; }
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) and (max-height: 720px) {
|
||||
.dpad { display: none; }
|
||||
.drop-key { top: auto; bottom: max(14px, env(safe-area-inset-bottom)); }
|
||||
}
|
||||
|
||||
@@ -284,7 +333,14 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
@keyframes artifact { 50% { transform: scale(.75); box-shadow: 0 0 18px var(--warning); } }
|
||||
@keyframes exit-open { 50% { outline: 3px solid var(--success); } }
|
||||
@keyframes pawn-in { from { transform: scale(.6); } }
|
||||
@keyframes bush-resist { 50% { transform: translateX(3px) rotate(1deg); } }
|
||||
@keyframes impact-north { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) - 10px)); } }
|
||||
@keyframes impact-east { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px) + 10px), calc(-50% + var(--map-offset-y, 0px))); } }
|
||||
@keyframes impact-south { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) + 10px)); } }
|
||||
@keyframes impact-west { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px) - 10px), calc(-50% + var(--map-offset-y, 0px))); } }
|
||||
@keyframes bump { 0%, 100% { opacity: 0; } 20%, 75% { opacity: 1; transform: translateX(-50%) translateX(4px); } 45% { transform: translateX(-50%) translateX(-4px); } }
|
||||
@keyframes modal-in { from { opacity: 0; transform: scale(.82); } }
|
||||
@keyframes count { 50% { transform: scale(1.08); } }
|
||||
@keyframes guide-hand { 50% { transform: translate(18px, -12px); } }
|
||||
@keyframes breakthrough-run { 0% { transform: translate(calc(-50% + var(--breakthrough-start-x)), calc(-50% + var(--breakthrough-start-y))) scale(.78, 1.16); } 42% { transform: translate(calc(-50% + var(--breakthrough-mid-x)), calc(-50% + var(--breakthrough-mid-y))) scale(1.16, .78); } 100% { transform: translate(-50%, -50%) scale(1); } }
|
||||
@keyframes breakthrough-accent { 0% { opacity: 0; transform: translate(0, 0) scale(.4); } 25% { opacity: 1; } 100% { opacity: 0; transform: translate(var(--breakthrough-start-x, 12px), -18px) scale(1.35); } }
|
||||
|
||||
+545
-77
@@ -2,6 +2,7 @@
|
||||
"use strict";
|
||||
|
||||
const TOKEN_KEY = "maze32.session.v1";
|
||||
const NAME_KEY = "maze32.player-name.v1";
|
||||
const MAX_VIEW_CHUNKS = 90;
|
||||
const MAX_VIEW_WIDTH = 32;
|
||||
const MAX_VIEW_HEIGHT = 32;
|
||||
@@ -14,15 +15,19 @@
|
||||
const AVATAR_DIRECTION_HYSTERESIS = .18;
|
||||
const AVATAR_SLOW_REPEAT_MS = 360;
|
||||
const AVATAR_FAST_REPEAT_MS = 170;
|
||||
const DIRECTIONAL_PAD_REPEAT_MS = 170;
|
||||
const LATENCY_SAMPLE_LIMIT = 256;
|
||||
const QUALITY_SAMPLE_LIMIT = 5;
|
||||
const HEARTBEAT_INTERVAL_MS = 5000;
|
||||
const HEARTBEAT_STALE_MS = 15000;
|
||||
const ONBOARDING_STEPS = Object.freeze([
|
||||
Object.freeze({icon: "hand", title: "Потяни героя", text: "Потяни героя — лабиринт поедет под ним."}),
|
||||
Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}),
|
||||
Object.freeze({icon: "maze", title: "Стена не пускает", text: "Упрись в стену — попробуй другую сторону."}),
|
||||
Object.freeze({icon: "spikes", title: "Стена не пускает", text: "Упрись в стену — попробуй другую сторону."}),
|
||||
Object.freeze({icon: "key", title: "Найди ключ", text: "Ключ открывает твой выход."}),
|
||||
Object.freeze({icon: "eye", title: "Глаз расширяет карту", text: "Возьми глаз — увидишь дальше."}),
|
||||
Object.freeze({icon: "crystal", title: "Кристалл расширяет карту", text: "Возьми кристалл — увидишь дальше."}),
|
||||
Object.freeze({icon: "arrow", title: "Стрелка знает путь", text: "Компас показывает сторону выхода."}),
|
||||
Object.freeze({icon: "exit", title: "Выходи с ключом", text: "Коснись своего выхода с ключом."}),
|
||||
Object.freeze({icon: "exit-closed", title: "Выходи с ключом", text: "Коснись своего выхода с ключом."}),
|
||||
Object.freeze({icon: "friends", title: "Встретил друга", text: "Другой герой может отдать найденный ключ."})
|
||||
]);
|
||||
const NAME_ADJECTIVES = Object.freeze(["Бодрый", "Хитрый", "Тихий", "Смелый", "Ловкий", "Сонный", "Шустрый", "Лунный", "Яркий", "Дикий", "Вечный", "Храбрый"]);
|
||||
@@ -47,7 +52,7 @@
|
||||
const HINTS = Object.freeze({none: "Молчит", n: "Север", ne: "Северо-восток", e: "Восток", se: "Юго-восток", s: "Юг", sw: "Юго-запад", w: "Запад", nw: "Северо-запад"});
|
||||
const HINT_ANGLES = Object.freeze({none: 0, n: 0, ne: 45, e: 90, se: 135, s: 180, sw: 225, w: 270, nw: 315});
|
||||
const PHASES = Object.freeze({countdown: "Обратный отсчёт", waiting: "Ждём игроков", active: "Гонка идёт", finish_pending: "Фотофиниш", paused: "Пауза", finished: "Финиш"});
|
||||
const ERRORS = Object.freeze({invalid_name: "Позывной должен быть длиной от 1 до 32 символов без пробелов по краям.", duplicate_name: "Этот позывной уже занят. Клоны сегодня не участвуют.", role_full: "Свободных мест больше нет.", invalid_token: "Старая сессия не найдена. Выбираем новый позывной.", session_connected: "Эта сессия уже открыта в другом окне.", not_owner: "Запустить гонку может только Игрок 1.", not_ready: "Для старта нужны оба игрока онлайн.", invalid_phase: "Сейчас эту команду выполнять нельзя.", rate_limited: "Слишком быстро! Сервер просит немного меньше турбо.", blocked: "Там стена. Очень убедительная.", no_key: "Карман пуст — бросать нечего.", player_exited: "Герой уже снаружи. Возвращаться за тапочками поздно.", match_finished: "Гонка уже закончилась.", stale_sequence: "Команда устарела и отправилась в музей.", unauthorized: "Сначала выберите роль или восстановите сессию.", text_required: "Сервер принимает только текстовые команды.", protocol_error: "Пакет не прошёл проверку протокола.", request_rejected: "Сервер отклонил команду.", no_suggestion: "Все смешные имена уже разобрали."});
|
||||
const ERRORS = Object.freeze({invalid_name: "Позывной должен быть длиной от 1 до 32 символов без пробелов по краям.", duplicate_name: "Этот позывной уже занят. Клоны сегодня не участвуют.", role_full: "Свободных мест больше нет.", invalid_token: "Старая сессия не найдена. Выбираем новый позывной.", session_connected: "Эта сессия уже открыта в другом окне.", not_owner: "Запустить гонку может только Игрок 1.", not_ready: "Для старта нужны оба игрока онлайн.", invalid_phase: "Сейчас эту команду выполнять нельзя.", no_key: "Карман пуст — бросать нечего.", player_exited: "Герой уже снаружи. Возвращаться за тапочками поздно.", match_finished: "Гонка уже закончилась.", stale_sequence: "Команда устарела и отправилась в музей.", unauthorized: "Сначала выберите роль или восстановите сессию.", text_required: "Команда потеряла слова по дороге.", protocol_error: "Лабиринт не понял этот знак.", request_rejected: "Лабиринт пока не принимает эту затею.", no_suggestion: "Все смешные имена уже разобрали."});
|
||||
|
||||
function randomIndex(limit, randomSource) {
|
||||
if (!Number.isInteger(limit) || limit < 1) return 0;
|
||||
@@ -147,13 +152,13 @@
|
||||
this.latest = null;
|
||||
}
|
||||
|
||||
request(direction) {
|
||||
request(direction, element) {
|
||||
if (!direction) return null;
|
||||
if (this.inFlight) {
|
||||
this.latest = direction;
|
||||
this.latest = {direction, element};
|
||||
return null;
|
||||
}
|
||||
const requestId = this.dispatch(direction);
|
||||
const requestId = this.dispatch(direction, element);
|
||||
if (requestId) this.inFlight = requestId;
|
||||
return requestId;
|
||||
}
|
||||
@@ -161,9 +166,9 @@
|
||||
complete(requestId) {
|
||||
if (!requestId || requestId !== this.inFlight) return null;
|
||||
this.inFlight = null;
|
||||
const direction = this.latest;
|
||||
const latest = this.latest;
|
||||
this.latest = null;
|
||||
return direction ? this.request(direction) : null;
|
||||
return latest ? this.request(latest.direction, latest.element) : null;
|
||||
}
|
||||
|
||||
release() { this.latest = null; }
|
||||
@@ -189,6 +194,18 @@
|
||||
};
|
||||
}
|
||||
|
||||
function playerFocalPoint(stageWidth, stageHeight) {
|
||||
if (![stageWidth, stageHeight].every(value => Number.isFinite(value) && value > 0)) return {x: 0, y: 0};
|
||||
if (stageWidth > stageHeight) return {x: Math.round(stageWidth * .42), y: Math.round(stageHeight * .45)};
|
||||
return {x: Math.round(stageWidth / 2), y: Math.round(stageHeight * .3)};
|
||||
}
|
||||
|
||||
function playerFocalMapOffset(selfIndex, width, minX, minY, columns, rows, cellSize, stageWidth, stageHeight) {
|
||||
const centered = playerMapOffset(selfIndex, width, minX, minY, columns, rows, cellSize);
|
||||
const focal = playerFocalPoint(stageWidth, stageHeight);
|
||||
return {x: centered.x + focal.x - stageWidth / 2, y: centered.y + focal.y - stageHeight / 2};
|
||||
}
|
||||
|
||||
function nextOnboardingStep(currentStep) {
|
||||
return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1;
|
||||
}
|
||||
@@ -236,6 +253,50 @@
|
||||
pointerSnapshot() { return this.pointerSamples.slice(); }
|
||||
}
|
||||
|
||||
class ConnectionQuality {
|
||||
constructor(now) {
|
||||
this.now = typeof now === "function" ? now : () => root.performance.now();
|
||||
this.pending = null;
|
||||
this.samples = [];
|
||||
this.latest = null;
|
||||
this.level = "unknown";
|
||||
}
|
||||
|
||||
begin(requestId) {
|
||||
if (this.pending || !requestId) return false;
|
||||
this.pending = {requestId, sent: this.now()};
|
||||
return true;
|
||||
}
|
||||
|
||||
acknowledge(requestId, handlingMs) {
|
||||
if (!this.pending || this.now() - this.pending.sent > HEARTBEAT_STALE_MS || this.pending.requestId !== requestId || !Number.isInteger(handlingMs) || handlingMs < 0 || handlingMs > 1000) return false;
|
||||
const totalMs = Math.max(0, this.now() - this.pending.sent);
|
||||
this.pending = null;
|
||||
this.samples.push(totalMs);
|
||||
if (this.samples.length > QUALITY_SAMPLE_LIMIT) this.samples.shift();
|
||||
const ordered = this.samples.slice().sort((left, right) => left - right);
|
||||
const smoothedMs = ordered[Math.floor(ordered.length / 2)];
|
||||
this.latest = {totalMs: smoothedMs, communicationMs: Math.max(0, smoothedMs - handlingMs), handlingMs, received: this.now()};
|
||||
if (this.level === "good") this.level = smoothedMs > 175 ? "delayed" : "good";
|
||||
else if (this.level === "slow") this.level = smoothedMs <= 450 ? "delayed" : "slow";
|
||||
else if (smoothedMs <= 150) this.level = "good";
|
||||
else this.level = smoothedMs <= 500 ? "delayed" : "slow";
|
||||
return true;
|
||||
}
|
||||
|
||||
reset() { this.pending = null; this.samples = []; this.latest = null; this.level = "unknown"; }
|
||||
|
||||
expirePending() {
|
||||
if (this.pending && this.now() - this.pending.sent > HEARTBEAT_STALE_MS) this.pending = null;
|
||||
}
|
||||
|
||||
status() {
|
||||
if (!this.latest) return "unknown";
|
||||
if (this.now() - this.latest.received > HEARTBEAT_STALE_MS) return "stale";
|
||||
return this.level;
|
||||
}
|
||||
}
|
||||
|
||||
function createEnvelope(type, sequence, requestId, payload) {
|
||||
return {v: 1, type, seq: sequence, requestId, payload: payload || {}};
|
||||
}
|
||||
@@ -245,7 +306,7 @@
|
||||
return false;
|
||||
}
|
||||
const cellLimit = snapshot.width * snapshot.height;
|
||||
return snapshot.cells.every(cell => Array.isArray(cell) && cell.length === 2 && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 15) && snapshot.objects.every(object => Array.isArray(object) && object.length === 4 && Number.isInteger(object[3]) && object[3] >= 0 && object[3] < cellLimit);
|
||||
return snapshot.cells.every(cell => Array.isArray(cell) && (cell.length === 3 || cell.length === 5) && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 15 && Number.isInteger(cell[2]) && cell[2] >= 0 && cell[2] <= 15 && (cell.length === 3 || (Number.isInteger(cell[3]) && cell[3] >= 0 && cell[3] <= 3 && Number.isInteger(cell[4]) && cell[4] >= 0 && cell[4] <= 3))) && snapshot.objects.every(object => Array.isArray(object) && object.length === 4 && Number.isInteger(object[3]) && object[3] >= 0 && object[3] < cellLimit);
|
||||
}
|
||||
|
||||
class SnapshotAssembler {
|
||||
@@ -289,6 +350,48 @@
|
||||
return snapshot && Array.isArray(snapshot.cells) ? snapshot.cells.map(cell => cell[0]) : [];
|
||||
}
|
||||
|
||||
function hedgeEdgeKey(x, y, vertical) {
|
||||
return `${vertical ? "v" : "h"}:${x}:${y}`;
|
||||
}
|
||||
|
||||
function hedgeVariant(edge, count) {
|
||||
const seed = ((edge.x + 1) * 1103515245 + (edge.y + 1) * 12345 + (edge.vertical ? 97 : 13)) >>> 0;
|
||||
return seed % count + 1;
|
||||
}
|
||||
|
||||
function projectedHedgeEdges(cells, width) {
|
||||
if (!Array.isArray(cells) || !Number.isInteger(width) || width < 1) return [];
|
||||
const present = new Set(cells.map(cell => cell[0]));
|
||||
const edges = new Map();
|
||||
const add = (x, y, vertical, opened) => {
|
||||
const key = hedgeEdgeKey(x, y, vertical);
|
||||
const existing = edges.get(key);
|
||||
edges.set(key, {key, x, y, vertical, opened: !!opened || !!(existing && existing.opened)});
|
||||
};
|
||||
cells.forEach(cell => {
|
||||
if (!Array.isArray(cell) || cell.length < 3 || !Number.isInteger(cell[0])) return;
|
||||
const index = cell[0]; const passages = cell[1]; const opened = cell[2];
|
||||
const x = index % width; const y = Math.floor(index / width);
|
||||
const eastVisible = present.has(index + 1) && (index + 1) % width !== 0;
|
||||
const southVisible = present.has(index + width);
|
||||
if (!(passages & 1) || (opened & 1)) add(x, y, false, opened & 1);
|
||||
if (!(passages & 8) || (opened & 8)) add(x, y, true, opened & 8);
|
||||
if ((!eastVisible && !(passages & 2)) || (opened & 2)) add(x + 1, y, true, opened & 2);
|
||||
if ((!southVisible && !(passages & 4)) || (opened & 4)) add(x, y + 1, false, opened & 4);
|
||||
});
|
||||
return Array.from(edges.values());
|
||||
}
|
||||
|
||||
function hedgeKeyForMove(cellIndex, width, direction) {
|
||||
if (!Number.isInteger(cellIndex) || !Number.isInteger(width) || width < 1) return null;
|
||||
const x = cellIndex % width; const y = Math.floor(cellIndex / width);
|
||||
if (direction === "north") return hedgeEdgeKey(x, y, false);
|
||||
if (direction === "east") return hedgeEdgeKey(x + 1, y, true);
|
||||
if (direction === "south") return hedgeEdgeKey(x, y + 1, false);
|
||||
if (direction === "west") return hedgeEdgeKey(x, y, true);
|
||||
return null;
|
||||
}
|
||||
|
||||
function clampObserverZoom(value) {
|
||||
return Math.min(6, Math.max(.25, value));
|
||||
}
|
||||
@@ -300,15 +403,65 @@
|
||||
|
||||
function boot(documentObject, windowObject) {
|
||||
const byId = id => documentObject.getElementById(id);
|
||||
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, startX: null, startY: null, direction: null, repeatTimer: null, repeatMs: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
|
||||
state.moveFlow = new LatestIntentFlow(direction => sendMoveNow(direction));
|
||||
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, heartbeatTimer: null, leaveRequest: null, noticeTimer: null, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null, breakthroughTimer: null, travelTimer: null}, hedge: {openedKeys: new Set()}, moveDirections: new Map(), touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), quality: new ConnectionQuality(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, startX: null, startY: null, direction: null, repeatTimer: null, repeatMs: null}, dpad: {pointerId: null, direction: null, repeatTimer: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
|
||||
state.moveFlow = new LatestIntentFlow((direction, element) => sendMoveNow(direction, element));
|
||||
|
||||
function showNotice(text, kind) {
|
||||
function showNotice(text, kind, priority, persistent, key) {
|
||||
const element = byId("notice");
|
||||
const nextPriority = priority == null ? 1 : priority;
|
||||
if (state.rail.persistent && !persistent && nextPriority < state.rail.priority) return;
|
||||
if (state.rail.key === key && element.textContent === text) return;
|
||||
element.textContent = text;
|
||||
element.className = `notice visible ${kind || ""}`;
|
||||
element.className = `message-rail ${kind || ""}`;
|
||||
element.hidden = false;
|
||||
windowObject.clearTimeout(state.noticeTimer);
|
||||
state.noticeTimer = windowObject.setTimeout(() => element.classList.remove("visible"), 2600);
|
||||
state.rail = {priority: nextPriority, key: key || null, persistent: !!persistent};
|
||||
if (!persistent) state.noticeTimer = windowObject.setTimeout(() => { element.hidden = true; state.rail = {priority: 0, key: null, persistent: false}; }, 2600);
|
||||
}
|
||||
|
||||
function clearEffects() {
|
||||
windowObject.clearTimeout(state.effects.impactTimer);
|
||||
windowObject.clearTimeout(state.effects.rateTimer);
|
||||
windowObject.clearTimeout(state.effects.breakthroughTimer);
|
||||
windowObject.clearTimeout(state.effects.travelTimer);
|
||||
state.effects.impactTimer = null;
|
||||
state.effects.rateTimer = null;
|
||||
state.effects.breakthroughTimer = null;
|
||||
state.effects.travelTimer = null;
|
||||
state.hedge.openedKeys.clear();
|
||||
documentObject.querySelectorAll(".breakthrough-runner").forEach(runner => runner.remove());
|
||||
byId("avatar-control").classList.remove("breakthrough-travelling");
|
||||
byId("maze").classList.remove("impact-north", "impact-east", "impact-south", "impact-west", "rate-limited");
|
||||
byId("wall-bump").classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west");
|
||||
documentObject.querySelectorAll(".cell.bush-impact").forEach(cell => cell.classList.remove("bush-impact", "bush-stage-1", "bush-stage-2", "bush-stage-3", "bush-stage-4"));
|
||||
}
|
||||
|
||||
function showImpact(direction, hits) {
|
||||
if (!direction) return;
|
||||
const maze = byId("maze");
|
||||
const bump = byId("wall-bump");
|
||||
windowObject.clearTimeout(state.effects.impactTimer);
|
||||
maze.classList.remove("impact-north", "impact-east", "impact-south", "impact-west");
|
||||
void maze.offsetWidth;
|
||||
maze.classList.add(`impact-${direction}`);
|
||||
bump.classList.remove("impact-north", "impact-east", "impact-south", "impact-west");
|
||||
bump.classList.add(`impact-${direction}`);
|
||||
bump.classList.add("show");
|
||||
const stage = Math.min(4, Math.max(1, Number.isInteger(hits) ? hits : 1));
|
||||
const target = state.snapshot && documentObject.querySelector(`.cell[data-cell-index="${state.snapshot.self}"]`);
|
||||
if (target && Number.isInteger(hits) && hits > 0) target.classList.add("bush-impact", `bush-stage-${stage}`);
|
||||
const hedgeKey = state.snapshot && hedgeKeyForMove(state.snapshot.self, state.snapshot.width, direction);
|
||||
const hedge = hedgeKey && maze.querySelector(`[data-hedge-key="${hedgeKey}"]`);
|
||||
const hedgeUse = hedge && hedge.querySelector("use");
|
||||
if (hedgeUse && Number.isInteger(hits) && hits > 0) hedgeUse.setAttribute("href", `/hedges.svg#hedge-impact-${stage}`);
|
||||
state.effects.impactTimer = windowObject.setTimeout(() => { maze.classList.remove("impact-north", "impact-east", "impact-south", "impact-west"); bump.classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west"); if (target) target.classList.remove("bush-impact", `bush-stage-${stage}`); if (hedgeUse) hedgeUse.setAttribute("href", `/hedges.svg#${hedge.dataset.hedgeSymbol}`); }, 340);
|
||||
}
|
||||
|
||||
function showRateLimit() {
|
||||
const maze = byId("maze");
|
||||
windowObject.clearTimeout(state.effects.rateTimer);
|
||||
maze.classList.add("rate-limited");
|
||||
state.effects.rateTimer = windowObject.setTimeout(() => maze.classList.remove("rate-limited"), 520);
|
||||
}
|
||||
|
||||
function setScreen(name) {
|
||||
@@ -316,6 +469,62 @@
|
||||
byId("lobby-screen").hidden = name !== "lobby";
|
||||
byId("match-screen").hidden = name !== "match";
|
||||
byId("observer-screen").hidden = name !== "observer";
|
||||
documentObject.body.classList.toggle("match-active", name === "match");
|
||||
["settings-mute", "settings-motion", "settings-help"].forEach(id => { byId(id).hidden = name !== "match"; });
|
||||
byId("settings-leave").hidden = state.session == null;
|
||||
if (name !== "match") stopBreakthroughTravel();
|
||||
if (name !== "match") closeSettings(false);
|
||||
}
|
||||
|
||||
function closeSettings(restoreFocus) {
|
||||
const menu = byId("settings-menu");
|
||||
if (menu.hidden) return;
|
||||
menu.hidden = true;
|
||||
const trigger = byId("settings-toggle");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
if (restoreFocus) trigger.focus();
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
const menu = byId("settings-menu");
|
||||
menu.hidden = false;
|
||||
byId("settings-toggle").setAttribute("aria-expanded", "true");
|
||||
const firstAction = menu.querySelector(".settings-item:not([hidden])");
|
||||
if (firstAction) firstAction.focus();
|
||||
}
|
||||
|
||||
function openLeaveConfirmation() {
|
||||
closeSettings(false);
|
||||
const phase = state.snapshot ? state.snapshot.phase : state.lobby && state.lobby.phase;
|
||||
const endsMatch = state.session && state.session.role === "player" &&
|
||||
["countdown", "active", "finish_pending", "paused", "finished"].includes(phase);
|
||||
byId("leave-text").textContent = endsMatch
|
||||
? "Все вернутся в лобби, а ваш слот сразу освободится."
|
||||
: "Освободим только ваше место в игре.";
|
||||
byId("leave-confirmation").hidden = false;
|
||||
byId("leave-accept").focus();
|
||||
}
|
||||
|
||||
function closeLeaveConfirmation() {
|
||||
byId("leave-confirmation").hidden = true;
|
||||
byId("settings-toggle").focus();
|
||||
}
|
||||
|
||||
function finishLeave() {
|
||||
windowObject.localStorage.removeItem(TOKEN_KEY);
|
||||
state.session = null;
|
||||
state.lobby = null;
|
||||
state.snapshot = null;
|
||||
state.leaveRequest = null;
|
||||
state.pending.clear();
|
||||
resetPlayerControls();
|
||||
clearEffects();
|
||||
stopHeartbeat();
|
||||
byId("maze").replaceChildren();
|
||||
byId("map-placeholder").hidden = false;
|
||||
byId("player-name").value = windowObject.localStorage.getItem(NAME_KEY) || "";
|
||||
closeLeaveConfirmation();
|
||||
setScreen("welcome");
|
||||
}
|
||||
|
||||
function renderWelcomeHeadline(headline) {
|
||||
@@ -329,10 +538,53 @@
|
||||
byId("connection").dataset.state = connectionState;
|
||||
byId("connection-text").textContent = text;
|
||||
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
|
||||
if (connectionState !== "online") resetAvatarControl();
|
||||
if (connectionState === "offline") { resetPlayerControls(); state.moveDirections.clear(); showNotice("Связь ушла гулять. Ждём её здесь.", "error", 3, true, "offline"); }
|
||||
else if (state.rail.persistent) showNotice("Связь вернулась! Кусты снова в игре.", "success", 3, false, "online");
|
||||
renderQuality();
|
||||
}
|
||||
|
||||
function renderQuality() {
|
||||
const quality = state.quality.latest;
|
||||
const status = !state.socket || state.socket.readyState !== windowObject.WebSocket.OPEN ? "offline" : state.quality.status();
|
||||
const indicator = byId("quality");
|
||||
indicator.dataset.state = status;
|
||||
if (!quality || status === "unknown" || status === "stale" || status === "offline") {
|
||||
const label = status === "offline" ? "нет связи" : status === "stale" ? "устарело" : "неизвестно";
|
||||
byId("quality-text").textContent = status === "offline" ? "нет" : "—";
|
||||
indicator.setAttribute("aria-label", `Отклик: ${label}`);
|
||||
byId("quality-details").textContent = `Отклик: ${label}`;
|
||||
return;
|
||||
}
|
||||
const total = Math.round(quality.totalMs);
|
||||
const communication = Math.round(quality.communicationMs);
|
||||
const handling = Math.round(quality.handlingMs);
|
||||
byId("quality-text").textContent = `${total} мс`;
|
||||
indicator.setAttribute("aria-label", `Отклик ${total} мс. Связь ${communication} мс. ESP ${handling} мс.`);
|
||||
byId("quality-details").textContent = `Отклик: ${total} мс · Связь: ${communication} мс · ESP: ${handling} мс. Связь — остаток транспорта, не только Wi‑Fi.`;
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
windowObject.clearTimeout(state.heartbeatTimer);
|
||||
state.heartbeatTimer = null;
|
||||
state.quality.reset();
|
||||
renderQuality();
|
||||
}
|
||||
|
||||
function scheduleHeartbeat(delay) {
|
||||
windowObject.clearTimeout(state.heartbeatTimer);
|
||||
state.heartbeatTimer = windowObject.setTimeout(() => {
|
||||
state.quality.expirePending();
|
||||
if (documentObject.hidden || !state.session || state.quality.pending || !state.socket || state.socket.readyState !== windowObject.WebSocket.OPEN) {
|
||||
scheduleHeartbeat(HEARTBEAT_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
send("heartbeat", {}, null);
|
||||
scheduleHeartbeat(HEARTBEAT_INTERVAL_MS);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
renderWelcomeHeadline(randomLoginHeadline());
|
||||
byId("player-name").value = windowObject.localStorage.getItem(NAME_KEY) || "";
|
||||
|
||||
function beginFeedback() {
|
||||
if (state.feedbackReady) return;
|
||||
@@ -378,9 +630,10 @@
|
||||
}
|
||||
const sequence = ++state.clientSequence;
|
||||
const requestId = `${type}-${sequence}`;
|
||||
if (type === "heartbeat" && !state.quality.begin(requestId)) return null;
|
||||
state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload)));
|
||||
setPending(requestId, element);
|
||||
if (type === "move") state.latency.sent(requestId);
|
||||
if (type === "move") { state.latency.sent(requestId); state.moveDirections.set(requestId, payload.direction); }
|
||||
return requestId;
|
||||
}
|
||||
|
||||
@@ -417,32 +670,143 @@
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function hedgeSymbol(edge) {
|
||||
return edge.opened ? `hedge-breach-${hedgeVariant(edge, 3)}` : `hedge-intact-${hedgeVariant(edge, 6)}`;
|
||||
}
|
||||
|
||||
function breakthroughDirection(previous, snapshot) {
|
||||
if (!previous || previous.role === "observer" || snapshot.role === "observer" || previous.width !== snapshot.width) return null;
|
||||
const delta = snapshot.self - previous.self;
|
||||
const directions = {[-snapshot.width]: "north", [1]: "east", [snapshot.width]: "south", [-1]: "west"};
|
||||
const direction = directions[delta];
|
||||
if (!direction || (direction === "east" && previous.self % snapshot.width === snapshot.width - 1) || (direction === "west" && previous.self % snapshot.width === 0)) return null;
|
||||
const key = hedgeKeyForMove(previous.self, snapshot.width, direction);
|
||||
return projectedHedgeEdges(snapshot.cells, snapshot.width).some(edge => edge.key === key && edge.opened) ? direction : null;
|
||||
}
|
||||
|
||||
function stopBreakthroughTravel() {
|
||||
windowObject.clearTimeout(state.effects.travelTimer);
|
||||
state.effects.travelTimer = null;
|
||||
documentObject.querySelectorAll(".breakthrough-runner").forEach(runner => runner.remove());
|
||||
byId("avatar-control").classList.remove("breakthrough-travelling");
|
||||
}
|
||||
|
||||
function showBreakthroughTravel(direction) {
|
||||
stopBreakthroughTravel();
|
||||
if (!direction || state.reducedMotion) return;
|
||||
const mapShell = byId("map-shell");
|
||||
const cellSize = Number.parseFloat(byId("maze").style.getPropertyValue("--cell"));
|
||||
if (!Number.isFinite(cellSize)) return;
|
||||
const offsets = {north: {x: 0, y: cellSize}, east: {x: -cellSize, y: 0}, south: {x: 0, y: -cellSize}, west: {x: cellSize, y: 0}};
|
||||
const runner = icon("player", `breakthrough-runner player-${state.session && state.session.slot === 2 ? "two" : "one"}`);
|
||||
runner.style.setProperty("--breakthrough-start-x", `${offsets[direction].x}px`);
|
||||
runner.style.setProperty("--breakthrough-start-y", `${offsets[direction].y}px`);
|
||||
runner.style.setProperty("--breakthrough-mid-x", `${offsets[direction].x * .42}px`);
|
||||
runner.style.setProperty("--breakthrough-mid-y", `${offsets[direction].y * .42}px`);
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const accent = documentObject.createElement("span");
|
||||
accent.className = `breakthrough-accent accent-${index + 1}`;
|
||||
runner.appendChild(accent);
|
||||
}
|
||||
mapShell.appendChild(runner);
|
||||
byId("avatar-control").classList.add("breakthrough-travelling");
|
||||
state.effects.travelTimer = windowObject.setTimeout(stopBreakthroughTravel, 480);
|
||||
}
|
||||
|
||||
function renderHedgeEdges(maze, cells, width, minX, minY) {
|
||||
const edges = projectedHedgeEdges(cells, width);
|
||||
const openedKeys = new Set(edges.filter(edge => edge.opened).map(edge => edge.key));
|
||||
const newOpenedKeys = new Set([...openedKeys].filter(key => !state.hedge.openedKeys.has(key)));
|
||||
const layer = documentObject.createElement("span");
|
||||
layer.className = "hedge-layer";
|
||||
edges.forEach(edge => {
|
||||
const wrapper = documentObject.createElement("span");
|
||||
const symbol = hedgeSymbol(edge);
|
||||
wrapper.className = `hedge-edge ${edge.vertical ? "hedge-vertical" : "hedge-horizontal"}`;
|
||||
wrapper.dataset.hedgeKey = edge.key;
|
||||
wrapper.dataset.hedgeSymbol = symbol;
|
||||
wrapper.style.setProperty("--hedge-x", String(edge.x - minX));
|
||||
wrapper.style.setProperty("--hedge-y", String(edge.y - minY));
|
||||
const svg = documentObject.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 160 40");
|
||||
svg.setAttribute("preserveAspectRatio", "none");
|
||||
const use = documentObject.createElementNS("http://www.w3.org/2000/svg", "use");
|
||||
use.setAttribute("href", `/hedges.svg#${symbol}`);
|
||||
svg.appendChild(use);
|
||||
wrapper.appendChild(svg);
|
||||
layer.appendChild(wrapper);
|
||||
});
|
||||
maze.appendChild(layer);
|
||||
state.hedge.openedKeys = openedKeys;
|
||||
if (newOpenedKeys.size && !state.reducedMotion) {
|
||||
newOpenedKeys.forEach(key => {
|
||||
const edge = layer.querySelector(`[data-hedge-key="${key}"]`);
|
||||
const use = edge && edge.querySelector("use");
|
||||
if (use) use.setAttribute("href", "/hedges.svg#hedge-breakthrough");
|
||||
});
|
||||
windowObject.clearTimeout(state.effects.breakthroughTimer);
|
||||
state.effects.breakthroughTimer = windowObject.setTimeout(() => {
|
||||
layer.querySelectorAll(".hedge-edge").forEach(edge => {
|
||||
const use = edge.querySelector("use");
|
||||
if (use) use.setAttribute("href", `/hedges.svg#${edge.dataset.hedgeSymbol}`);
|
||||
});
|
||||
}, 340);
|
||||
}
|
||||
}
|
||||
|
||||
function addObject(cell, object) {
|
||||
const [kind, owner, variant] = object;
|
||||
let marker;
|
||||
const ownerVariant = owner === 1 ? "two" : "one";
|
||||
if (kind === "key") {
|
||||
marker = icon("key", "object object-key"); marker.dataset.owner = owner === 1 ? "two" : "one";
|
||||
marker = icon("key", "object object-key"); marker.dataset.owner = ownerVariant;
|
||||
} else if (kind === "exit") {
|
||||
marker = documentObject.createElement("span"); marker.className = "object object-exit"; marker.dataset.owner = owner === 1 ? "two" : "one"; marker.dataset.open = String(variant);
|
||||
marker = icon(variant ? "exit-open" : "exit-closed", "object object-exit"); marker.dataset.owner = ownerVariant; marker.dataset.open = String(variant);
|
||||
} else if (kind === "compass") {
|
||||
marker = icon("arrow", "object object-compass");
|
||||
} else if (kind === "vision") {
|
||||
marker = icon("eye", "object object-vision");
|
||||
marker = icon("crystal", "object object-vision");
|
||||
} else {
|
||||
marker = documentObject.createElement("span"); marker.className = "object object-landmark"; marker.textContent = ["♣", "✿", "♠", "⌂"][variant % 4] || "♣";
|
||||
}
|
||||
cell.appendChild(marker);
|
||||
}
|
||||
|
||||
function addPawn(cell, label, opponent) {
|
||||
const pawn = documentObject.createElement("span");
|
||||
pawn.className = opponent ? "pawn pawn-opponent" : "pawn pawn-self";
|
||||
const text = documentObject.createElement("span"); text.textContent = label;
|
||||
pawn.appendChild(text); cell.appendChild(pawn);
|
||||
function addPawn(cell, slot, opponent) {
|
||||
const pawn = icon("player", `pawn ${opponent ? "pawn-opponent" : "pawn-self"} pawn-owner-${slot === 2 ? "two" : "one"}`);
|
||||
cell.appendChild(pawn);
|
||||
}
|
||||
|
||||
function characterForSlot(slot) {
|
||||
return slot === 2 ? {symbol: "◆", name: "Мышонок"} : {symbol: "●", name: "Лисёнок"};
|
||||
return slot === 2 ? {name: "Синий герой", keyName: "синего игрока", owner: "two"} : {name: "Жёлтый герой", keyName: "жёлтого игрока", owner: "one"};
|
||||
}
|
||||
|
||||
function renderTrails(maze, cells, width, minX, minY, columns, rows) {
|
||||
const layer = documentObject.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
layer.classList.add("trail-layer");
|
||||
layer.setAttribute("viewBox", `0 0 ${columns} ${rows}`);
|
||||
layer.setAttribute("preserveAspectRatio", "none");
|
||||
layer.setAttribute("aria-hidden", "true");
|
||||
const paths = [[], []];
|
||||
cells.forEach(cell => {
|
||||
if (!Array.isArray(cell) || cell.length !== 5) return;
|
||||
const x = cell[0] % width - minX;
|
||||
const y = Math.floor(cell[0] / width) - minY;
|
||||
[[cell[3], x + 1, y], [cell[4], x, y + 1]].forEach(([owners, endX, endY]) => {
|
||||
if (!owners) return;
|
||||
const segment = `M ${x + .5} ${y + .5} L ${endX + .5} ${endY + .5}`;
|
||||
if (owners & 1) paths[0].push(segment);
|
||||
if (owners & 2) paths[1].push(segment);
|
||||
});
|
||||
});
|
||||
paths.forEach((segments, owner) => {
|
||||
if (!segments.length) return;
|
||||
const path = documentObject.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.classList.add("trail-path", owner === 0 ? "trail-player-one" : "trail-player-two");
|
||||
path.setAttribute("d", segments.join(" "));
|
||||
layer.appendChild(path);
|
||||
});
|
||||
maze.appendChild(layer);
|
||||
}
|
||||
|
||||
function renderMap(snapshot) {
|
||||
@@ -452,33 +816,36 @@
|
||||
byId("map-placeholder").hidden = false;
|
||||
return;
|
||||
}
|
||||
const coordinates = snapshot.cells.map(cell => ({index: cell[0], x: cell[0] % snapshot.width, y: Math.floor(cell[0] / snapshot.width), passages: cell[1]}));
|
||||
const coordinates = snapshot.cells.map(cell => ({index: cell[0], x: cell[0] % snapshot.width, y: Math.floor(cell[0] / snapshot.width), passages: cell[1], opened: cell[2]}));
|
||||
const minX = Math.min(...coordinates.map(cell => cell.x)); const maxX = Math.max(...coordinates.map(cell => cell.x));
|
||||
const minY = Math.min(...coordinates.map(cell => cell.y)); const maxY = Math.max(...coordinates.map(cell => cell.y));
|
||||
const columns = maxX - minX + 1;
|
||||
const rows = maxY - minY + 1;
|
||||
const mapShell = byId("map-shell");
|
||||
const cellSize = playerCellSize(mapShell.clientWidth, mapShell.clientHeight, columns, rows);
|
||||
const offset = playerMapOffset(snapshot.self, snapshot.width, minX, minY, columns, rows, cellSize);
|
||||
const focal = playerFocalPoint(mapShell.clientWidth, mapShell.clientHeight);
|
||||
const offset = playerFocalMapOffset(snapshot.self, snapshot.width, minX, minY, columns, rows, cellSize, mapShell.clientWidth, mapShell.clientHeight);
|
||||
maze.style.setProperty("--cell", `${cellSize}px`);
|
||||
maze.style.gridTemplateColumns = `repeat(${columns}, var(--cell))`;
|
||||
maze.style.gridTemplateRows = `repeat(${rows}, var(--cell))`;
|
||||
maze.style.transform = `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px))`;
|
||||
maze.style.setProperty("--map-offset-x", `${offset.x}px`);
|
||||
maze.style.setProperty("--map-offset-y", `${offset.y}px`);
|
||||
mapShell.style.setProperty("--player-focal-x", `${focal.x}px`);
|
||||
mapShell.style.setProperty("--player-focal-y", `${focal.y}px`);
|
||||
const objects = new Map();
|
||||
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
|
||||
coordinates.forEach(data => {
|
||||
const cell = documentObject.createElement("span");
|
||||
cell.className = "cell";
|
||||
cell.dataset.cellIndex = String(data.index);
|
||||
cell.style.gridColumn = String(data.x - minX + 1); cell.style.gridRow = String(data.y - minY + 1);
|
||||
if (!(data.passages & 1)) cell.classList.add("wall-n");
|
||||
if (!(data.passages & 2)) cell.classList.add("wall-e");
|
||||
if (!(data.passages & 4)) cell.classList.add("wall-s");
|
||||
if (!(data.passages & 8)) cell.classList.add("wall-w");
|
||||
(objects.get(data.index) || []).forEach(object => addObject(cell, object));
|
||||
const slot = state.session ? state.session.slot : 1;
|
||||
if (data.index === snapshot.opponent) addPawn(cell, characterForSlot(slot === 1 ? 2 : 1).symbol, true);
|
||||
if (data.index === snapshot.opponent) addPawn(cell, slot === 1 ? 2 : 1, true);
|
||||
maze.appendChild(cell);
|
||||
});
|
||||
renderTrails(maze, snapshot.cells, snapshot.width, minX, minY, columns, rows);
|
||||
renderHedgeEdges(maze, snapshot.cells, snapshot.width, minX, minY);
|
||||
byId("map-placeholder").hidden = true;
|
||||
}
|
||||
|
||||
@@ -487,21 +854,22 @@
|
||||
renderObserverSnapshot(snapshot);
|
||||
return;
|
||||
}
|
||||
const previous = state.snapshot;
|
||||
const direction = breakthroughDirection(previous, snapshot);
|
||||
state.snapshot = snapshot;
|
||||
setScreen("match");
|
||||
const slot = state.session && state.session.slot ? state.session.slot : 1;
|
||||
const character = characterForSlot(slot);
|
||||
byId("self-slot").textContent = character.symbol;
|
||||
byId("self-slot").setAttribute("aria-label", character.name);
|
||||
byId("avatar-handle-symbol").textContent = character.symbol;
|
||||
byId("avatar-control").setAttribute("aria-label", `Тянуть героя ${character.name} для движения`);
|
||||
byId("self-name").textContent = playerName();
|
||||
byId("identity").className = `identity player-${slot === 1 ? "one" : "two"}`;
|
||||
byId("match-screen").classList.toggle("player-two-mode", slot === 2);
|
||||
byId("match-state").textContent = PHASES[snapshot.phase] || "Гонка идёт";
|
||||
byId("inventory").textContent = snapshot.inventory == null ? "Пусто" : `Ключ ${characterForSlot(snapshot.inventory + 1).symbol}`;
|
||||
byId("inventory").textContent = snapshot.inventory == null ? "Пусто" : `Ключ ${characterForSlot(snapshot.inventory + 1).keyName}`;
|
||||
byId("inventory-stat").dataset.filled = String(snapshot.inventory != null);
|
||||
byId("drop-key").disabled = snapshot.inventory == null;
|
||||
setDirectionalPadEnabled(snapshot.phase === "active" && state.socket && state.socket.readyState === windowObject.WebSocket.OPEN);
|
||||
byId("vision").textContent = snapshot.visionMoves ? `${snapshot.visionMoves} ход.` : "Нет";
|
||||
byId("vision-stat").classList.toggle("vision-active", snapshot.visionMoves > 0);
|
||||
const hint = HINTS[snapshot.hint] ? snapshot.hint : "none";
|
||||
@@ -509,15 +877,14 @@
|
||||
byId("compass-stat").dataset.direction = hint;
|
||||
byId("compass-arrow").style.transform = `rotate(${HINT_ANGLES[hint]}deg)`;
|
||||
renderMap(snapshot);
|
||||
if (direction) showBreakthroughTravel(direction);
|
||||
else stopBreakthroughTravel();
|
||||
if (Number.isInteger(snapshot.stateSequence)) {
|
||||
windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence));
|
||||
}
|
||||
if (snapshot.phase === "paused") { resetAvatarControl(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
|
||||
if (snapshot.phase === "paused") { resetPlayerControls(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
|
||||
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
|
||||
else {
|
||||
hideModal();
|
||||
if (!state.onboardingSeen) showOnboarding(0);
|
||||
}
|
||||
else hideModal();
|
||||
}
|
||||
|
||||
function applyObserverTransform() {
|
||||
@@ -541,32 +908,29 @@
|
||||
setScreen("observer");
|
||||
const maze = byId("observer-maze");
|
||||
maze.replaceChildren();
|
||||
maze.style.setProperty("--cell", `${OBSERVER_CELL_SIZE}px`);
|
||||
maze.style.gridTemplateColumns = `repeat(${snapshot.width}, ${OBSERVER_CELL_SIZE}px)`;
|
||||
maze.style.gridTemplateRows = `repeat(${snapshot.height}, ${OBSERVER_CELL_SIZE}px)`;
|
||||
const objects = new Map();
|
||||
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
|
||||
const players = Array.isArray(snapshot.players) ? snapshot.players : [];
|
||||
snapshot.cells.forEach(data => {
|
||||
const index = data[0]; const passages = data[1];
|
||||
const index = data[0]; const passages = data[1]; const opened = data[2];
|
||||
const cell = documentObject.createElement("span");
|
||||
cell.className = "cell";
|
||||
cell.style.gridColumn = String(index % snapshot.width + 1);
|
||||
cell.style.gridRow = String(Math.floor(index / snapshot.width) + 1);
|
||||
if (!(passages & 1)) cell.classList.add("wall-n");
|
||||
if (!(passages & 2)) cell.classList.add("wall-e");
|
||||
if (!(passages & 4)) cell.classList.add("wall-s");
|
||||
if (!(passages & 8)) cell.classList.add("wall-w");
|
||||
(objects.get(index) || []).forEach(object => addObject(cell, object));
|
||||
players.forEach((player, playerIndex) => {
|
||||
if (player.cell === index && !player.exited) {
|
||||
const pawn = documentObject.createElement("span");
|
||||
pawn.className = `pawn pawn-player-${playerIndex === 0 ? "one" : "two"}`;
|
||||
const label = documentObject.createElement("span"); label.textContent = `P${playerIndex + 1}`;
|
||||
pawn.appendChild(label); cell.appendChild(pawn);
|
||||
const pawn = icon("player", `pawn pawn-player-${playerIndex === 0 ? "one" : "two"} pawn-owner-${playerIndex === 0 ? "one" : "two"}`);
|
||||
cell.appendChild(pawn);
|
||||
}
|
||||
});
|
||||
maze.appendChild(cell);
|
||||
});
|
||||
renderTrails(maze, snapshot.cells, snapshot.width, 0, 0, snapshot.width, snapshot.height);
|
||||
renderHedgeEdges(maze, snapshot.cells, snapshot.width, 0, 0);
|
||||
players.forEach((player, index) => {
|
||||
const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`;
|
||||
byId(`observer-p${index + 1}`).textContent = !player.connected ? "нет связи" : player.exited ? "снаружи" : key;
|
||||
@@ -580,7 +944,8 @@
|
||||
else if (snapshot.phase !== "paused") hideModal();
|
||||
}
|
||||
|
||||
function showModal(title, text, kicker, countdown) {
|
||||
function showModal(title, text, kicker, countdown, iconName) {
|
||||
byId("modal-icon").querySelector("use").setAttribute("href", `/icons.svg#${iconName || "hourglass"}`);
|
||||
byId("modal-title").textContent = title; byId("modal-text").textContent = text; byId("modal-kicker").textContent = kicker;
|
||||
byId("countdown").hidden = !countdown; byId("modal").hidden = false;
|
||||
}
|
||||
@@ -614,8 +979,8 @@
|
||||
title = slot ? (winner === slot ? "Победа!" : "Победил соперник") : `Победил Игрок ${winner}`;
|
||||
text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат.";
|
||||
}
|
||||
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false);
|
||||
resetAvatarControl();
|
||||
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false, "exit-open");
|
||||
resetPlayerControls();
|
||||
byId("modal-action").textContent = state.session && state.session.role === "player" && state.session.slot === 1 ? "Новая гонка" : "Вернуться в лобби";
|
||||
byId("modal-action").hidden = false;
|
||||
}
|
||||
@@ -627,7 +992,7 @@
|
||||
const timer = windowObject.setInterval(() => { index++; if (index < steps.length) byId("countdown").textContent = steps[index]; else windowObject.clearInterval(timer); }, 1000);
|
||||
}
|
||||
|
||||
function localError(code) { return ERRORS[code] || "Сервер отклонил команду. Подробности остались у него."; }
|
||||
function localError(code) { return ERRORS[code] || "Лабиринт пока не принимает эту затею."; }
|
||||
|
||||
function handleMessage(message) {
|
||||
if (!message || message.v !== 1 || !Number.isInteger(message.seq)) return;
|
||||
@@ -643,7 +1008,9 @@
|
||||
clearPending(message.requestId);
|
||||
if (message.type === "session") {
|
||||
state.session = message.payload; windowObject.localStorage.setItem(TOKEN_KEY, message.payload.token);
|
||||
if (message.payload.role === "player") windowObject.localStorage.setItem(NAME_KEY, byId("player-name").value.slice(0, 32));
|
||||
showNotice(message.payload.role === "player" ? `Слот P${message.payload.slot} закреплён за этим браузером.` : "Вы в зрительном зале. Руками не трогаем!", "success");
|
||||
scheduleHeartbeat(0);
|
||||
if (state.lobby) renderLobby();
|
||||
} else if (message.type === "lobby") { state.lobby = message.payload; renderLobby();
|
||||
} else if (message.type === "countdown") { showCountdown(message.payload);
|
||||
@@ -651,12 +1018,24 @@
|
||||
} else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false);
|
||||
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
|
||||
} else if (message.type === "match_result") { renderResult(message.payload.result);
|
||||
} else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); resetAvatarControl(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
|
||||
} else if (message.type === "ack") { state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); state.moveFlow.complete(message.requestId); feedback(true);
|
||||
} else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); resetPlayerControls(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
|
||||
} else if (message.type === "ack") {
|
||||
if (message.payload && message.payload.command === "leave" && message.requestId === state.leaveRequest) {
|
||||
finishLeave();
|
||||
return;
|
||||
}
|
||||
if (message.payload && message.payload.command === "heartbeat") {
|
||||
if (state.quality.acknowledge(message.requestId, message.payload.handlingMs)) renderQuality();
|
||||
return;
|
||||
}
|
||||
state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); state.moveDirections.delete(message.requestId); state.moveFlow.complete(message.requestId); feedback(true);
|
||||
} else if (message.type === "error") {
|
||||
const direction = state.moveDirections.get(message.requestId);
|
||||
state.moveDirections.delete(message.requestId);
|
||||
state.moveFlow.complete(message.requestId);
|
||||
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
|
||||
if (message.payload.code === "blocked") { const bump = byId("wall-bump"); bump.textContent = localError("blocked"); bump.classList.remove("show"); void bump.offsetWidth; bump.classList.add("show"); }
|
||||
if (message.payload.code === "blocked") { showImpact(direction, message.payload.breakthroughHits); feedback(false); return; }
|
||||
if (message.payload.code === "rate_limited") { showRateLimit(); feedback(false); return; }
|
||||
showNotice(localError(message.payload.code), "error"); feedback(false);
|
||||
}
|
||||
}
|
||||
@@ -666,17 +1045,45 @@
|
||||
const scheme = windowObject.location.protocol === "https:" ? "wss" : "ws";
|
||||
state.socket = new windowObject.WebSocket(`${scheme}://${windowObject.location.host}/ws`);
|
||||
state.socket.onopen = () => { setConnection("online", "ESP на связи"); const token = windowObject.localStorage.getItem(TOKEN_KEY); if (token) send("restore", {token}, null); };
|
||||
state.socket.onmessage = event => { let message; try { message = JSON.parse(event.data); } catch (_) { showNotice("Сервер прислал нечитаемый пакет.", "error"); return; } handleMessage(message); };
|
||||
state.socket.onclose = () => { state.moveFlow.disconnect(); setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
|
||||
state.socket.onerror = () => showNotice("WebSocket споткнулся. Пробуем снова…", "error");
|
||||
state.socket.onmessage = event => { let message; try { message = JSON.parse(event.data); } catch (_) { showNotice("Лабиринт прислал каракули. Ждём понятный знак.", "error"); return; } handleMessage(message); };
|
||||
state.socket.onclose = () => { state.moveFlow.disconnect(); stopHeartbeat(); setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
|
||||
state.socket.onerror = () => showNotice("Связь чихнула. Пробуем снова…", "error");
|
||||
}
|
||||
|
||||
function move(direction, element) {
|
||||
if (direction) state.moveFlow.request(direction, element);
|
||||
}
|
||||
|
||||
function sendMoveNow(direction) {
|
||||
return send("move", {direction}, byId("avatar-control"));
|
||||
function sendMoveNow(direction, element) {
|
||||
return send("move", {direction}, element || byId("avatar-control"));
|
||||
}
|
||||
|
||||
function setDirectionalPadEnabled(enabled) {
|
||||
documentObject.querySelectorAll("[data-direction]").forEach(button => {
|
||||
button.disabled = !enabled;
|
||||
});
|
||||
if (!enabled) resetDirectionalPad();
|
||||
}
|
||||
|
||||
function resetDirectionalPad() {
|
||||
windowObject.clearTimeout(state.dpad.repeatTimer);
|
||||
state.dpad.pointerId = null;
|
||||
state.dpad.direction = null;
|
||||
state.dpad.repeatTimer = null;
|
||||
documentObject.querySelectorAll("[data-direction]").forEach(button => button.classList.remove("active"));
|
||||
state.moveFlow.release();
|
||||
}
|
||||
|
||||
function repeatDirectionalPadMove() {
|
||||
if (!state.dpad.direction || state.dpad.pointerId == null) return;
|
||||
move(state.dpad.direction, documentObject.querySelector(`[data-direction="${state.dpad.direction}"]`));
|
||||
state.dpad.repeatTimer = windowObject.setTimeout(repeatDirectionalPadMove, DIRECTIONAL_PAD_REPEAT_MS);
|
||||
}
|
||||
|
||||
function resetPlayerControls() {
|
||||
resetAvatarControl();
|
||||
resetDirectionalPad();
|
||||
stopBreakthroughTravel();
|
||||
}
|
||||
|
||||
function resetAvatarControl() {
|
||||
@@ -730,22 +1137,32 @@
|
||||
|
||||
function setMuted(muted) {
|
||||
state.muted = muted;
|
||||
const button = byId("mute-toggle");
|
||||
const button = byId("settings-mute");
|
||||
button.setAttribute("aria-pressed", String(muted));
|
||||
button.setAttribute("aria-label", muted ? "Включить звук" : "Выключить звук");
|
||||
button.title = muted ? "Включить звук" : "Выключить звук";
|
||||
button.querySelector("use").setAttribute("href", muted ? "/icons.svg#sound" : "/icons.svg#sound");
|
||||
button.textContent = muted ? "Звук: выкл" : "Звук: вкл";
|
||||
}
|
||||
|
||||
function setReducedMotion(reduced) {
|
||||
state.reducedMotion = reduced;
|
||||
if (reduced) stopBreakthroughTravel();
|
||||
documentObject.documentElement.dataset.reducedMotion = String(reduced);
|
||||
byId("motion-toggle").setAttribute("aria-pressed", String(reduced));
|
||||
byId("motion-toggle").setAttribute("aria-label", reduced ? "Включить движение" : "Уменьшить движение");
|
||||
const button = byId("settings-motion");
|
||||
button.setAttribute("aria-pressed", String(reduced));
|
||||
button.textContent = reduced ? "Движение: выкл" : "Движение: вкл";
|
||||
}
|
||||
|
||||
documentObject.addEventListener("pointerdown", beginFeedback, {once: true});
|
||||
documentObject.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape" && !byId("leave-confirmation").hidden) {
|
||||
event.preventDefault();
|
||||
closeLeaveConfirmation();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && !byId("settings-menu").hidden) {
|
||||
event.preventDefault();
|
||||
closeSettings(true);
|
||||
return;
|
||||
}
|
||||
if (event.target instanceof windowObject.HTMLInputElement || byId("match-screen").hidden) return;
|
||||
beginFeedback();
|
||||
const direction = directionFromKey(event.code); if (direction) { event.preventDefault(); move(direction, null); }
|
||||
@@ -771,13 +1188,60 @@
|
||||
byId("avatar-control").addEventListener("pointerup", stopAvatarControl);
|
||||
byId("avatar-control").addEventListener("pointercancel", stopAvatarControl);
|
||||
byId("avatar-control").addEventListener("lostpointercapture", stopAvatarControl);
|
||||
windowObject.addEventListener("blur", resetAvatarControl);
|
||||
documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetAvatarControl(); });
|
||||
documentObject.querySelectorAll("[data-direction]").forEach(button => button.addEventListener("click", () => move(button.dataset.direction, button)));
|
||||
windowObject.addEventListener("blur", resetPlayerControls);
|
||||
const refreshPlayerFocalPoint = () => {
|
||||
if (state.snapshot && state.snapshot.role === "player") windowObject.requestAnimationFrame(() => renderMap(state.snapshot));
|
||||
};
|
||||
windowObject.addEventListener("resize", refreshPlayerFocalPoint);
|
||||
windowObject.addEventListener("orientationchange", refreshPlayerFocalPoint);
|
||||
documentObject.addEventListener("visibilitychange", () => {
|
||||
if (documentObject.hidden) {
|
||||
resetPlayerControls();
|
||||
stopHeartbeat();
|
||||
} else if (state.session && state.socket && state.socket.readyState === windowObject.WebSocket.OPEN) {
|
||||
scheduleHeartbeat(0);
|
||||
}
|
||||
});
|
||||
documentObject.querySelectorAll("[data-direction]").forEach(button => {
|
||||
button.addEventListener("pointerdown", event => {
|
||||
if (button.disabled) return;
|
||||
event.preventDefault();
|
||||
beginFeedback();
|
||||
resetDirectionalPad();
|
||||
state.dpad.pointerId = event.pointerId;
|
||||
state.dpad.direction = button.dataset.direction;
|
||||
button.classList.add("active");
|
||||
if (button.setPointerCapture) button.setPointerCapture(event.pointerId);
|
||||
move(state.dpad.direction, button);
|
||||
state.dpad.repeatTimer = windowObject.setTimeout(repeatDirectionalPadMove, DIRECTIONAL_PAD_REPEAT_MS);
|
||||
});
|
||||
const stopDirectionalPad = event => {
|
||||
if (event.pointerId === state.dpad.pointerId) resetDirectionalPad();
|
||||
};
|
||||
button.addEventListener("pointerup", stopDirectionalPad);
|
||||
button.addEventListener("pointercancel", stopDirectionalPad);
|
||||
button.addEventListener("lostpointercapture", stopDirectionalPad);
|
||||
button.addEventListener("contextmenu", event => event.preventDefault());
|
||||
button.addEventListener("click", event => {
|
||||
if (event.detail === 0 && !button.disabled) move(button.dataset.direction, button);
|
||||
});
|
||||
});
|
||||
byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key")));
|
||||
byId("mute-toggle").addEventListener("click", () => setMuted(!state.muted));
|
||||
byId("motion-toggle").addEventListener("click", () => setReducedMotion(!state.reducedMotion));
|
||||
byId("onboarding-replay").addEventListener("click", () => showOnboarding(0));
|
||||
byId("settings-toggle").addEventListener("click", () => {
|
||||
if (byId("settings-menu").hidden) openSettings();
|
||||
else closeSettings(false);
|
||||
});
|
||||
byId("settings-mute").addEventListener("click", () => setMuted(!state.muted));
|
||||
byId("settings-motion").addEventListener("click", () => setReducedMotion(!state.reducedMotion));
|
||||
byId("settings-help").addEventListener("click", () => { closeSettings(false); showOnboarding(0); });
|
||||
byId("settings-wifi").addEventListener("click", () => closeSettings(false));
|
||||
byId("settings-leave").addEventListener("click", openLeaveConfirmation);
|
||||
byId("leave-cancel").addEventListener("click", closeLeaveConfirmation);
|
||||
byId("leave-accept").addEventListener("click", () => {
|
||||
const requestId = send("leave", {}, byId("leave-accept"));
|
||||
if (requestId) state.leaveRequest = requestId;
|
||||
else showNotice("Не удалось отправить выход. Связь продолжается.", "error");
|
||||
});
|
||||
byId("onboarding-next").addEventListener("click", () => {
|
||||
const next = nextOnboardingStep(state.onboardingStep);
|
||||
if (next == null) hideOnboarding();
|
||||
@@ -802,7 +1266,11 @@
|
||||
const stopObserverDrag = event => { if (state.observer.dragging && state.observer.dragging.id === event.pointerId) { state.observer.dragging = null; byId("observer-stage").classList.remove("dragging"); } };
|
||||
byId("observer-stage").addEventListener("pointerup", stopObserverDrag);
|
||||
byId("observer-stage").addEventListener("pointercancel", stopObserverDrag);
|
||||
byId("details-toggle").addEventListener("click", () => { const panel = byId("details-panel"); panel.hidden = !panel.hidden; byId("details-toggle").setAttribute("aria-expanded", String(!panel.hidden)); });
|
||||
documentObject.addEventListener("pointerdown", event => {
|
||||
const menu = byId("settings-menu");
|
||||
const trigger = byId("settings-toggle");
|
||||
if (!menu.hidden && !menu.contains(event.target) && !trigger.contains(event.target)) closeSettings(false);
|
||||
});
|
||||
byId("modal-action").addEventListener("click", () => {
|
||||
if (state.session && state.session.role === "player" && state.session.slot === 1 && state.snapshot && state.snapshot.phase === "finished") {
|
||||
send("restart", {}, byId("modal-action"));
|
||||
@@ -814,7 +1282,7 @@
|
||||
return {state, handleMessage, move, resetAvatarControl, latencySamples: () => state.latency.snapshot(), pointerFeedbackSamples: () => state.latency.pointerSnapshot()};
|
||||
}
|
||||
|
||||
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot});
|
||||
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_ADJECTIVES, NAME_NOUNS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, QUALITY_SAMPLE_LIMIT, HEARTBEAT_INTERVAL_MS, HEARTBEAT_STALE_MS, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, DIRECTIONAL_PAD_REPEAT_MS, LatestIntentFlow, LatencyTracker, ConnectionQuality, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, playerFocalPoint, playerFocalMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, hedgeEdgeKey, hedgeKeyForMove, hedgeVariant, projectedHedgeEdges, clampObserverZoom, observerFitScale, boot});
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
root.Maze32Player = api;
|
||||
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });
|
||||
|
||||
Reference in New Issue
Block a user