feat(trails): render authoritative player route trails

This commit is contained in:
2026-09-13 23:31:26 +03:00
parent 8ad856b931
commit 3518f8a152
12 changed files with 367 additions and 6 deletions
+5
View File
@@ -201,6 +201,11 @@ 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
Every gameplay command follows the same pipeline:
+8
View File
@@ -221,6 +221,14 @@ Browser controls must support:
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
- The browser UI uses one dark, humorous retro arcade visual language across
+205 -1
View File
@@ -3436,7 +3436,7 @@ resources.
## Milestone 030 — Explicit session exit and slot release
**Status:** `IN_PROGRESS`
**Status:** `COMPLETE`
### Objective
@@ -3644,3 +3644,207 @@ observer departure or a lobby departure must not reset gameplay.
flow in the browser and confirmed that the issue was fixed: a remaining
participant is returned from the result modal to a usable waiting lobby when
the other player explicitly leaves.
## Milestone 031 — Authoritative persistent player trails
**Status:** `IN_PROGRESS`
### Objective
Show a persistent dotted record of every successful route used by each player
during the current match, using the established yellow and blue ownership
colors and a mixed two-color treatment for shared passages. Each player may see
trail data only inside the current server-approved visibility projection, while
observers may see both complete trails. Trails must survive refresh and
reconnection without adding client requests or materially increasing ESP,
network, or rendering load.
### Scope
- Do not start implementation until every preceding milestone is `COMPLETE`.
Milestone 030 is `IN_PROGRESS` when this plan is created, so this milestone
must remain `NOT_STARTED` until Milestone 030 is complete.
- Keep trail ownership authoritative on the ESP. A browser-only history cannot
provide consistent trails to another player and observers after refresh or
reconnection, and must not be used as the source of truth.
- Record only accepted authoritative player transitions between cells. Mark
ordinary successful moves and the successful fifth-impact movement through a
newly opened hedge. Do not mark blocked, rejected, rate-limited, stale,
queued-but-superseded, or otherwise unsuccessful movement attempts.
- Represent an undirected traversed cell edge with a compact two-bit ownership
mask: Player 1, Player 2, both, or neither. Use a fixed-size per-match layout
derived from the bounded maze dimensions; do not allocate per step, store an
ordered movement history, or create a collection that grows with match
duration.
- Preserve the trail for the complete current match, including countdown-to-
active lifecycle, pause, normal reconnect, and page refresh. Clear it exactly
when authoritative match state is reset, cancelled, replaced by a new match,
or cleared by device reboot.
- Piggyback compact trail ownership on the existing role-appropriate view
projection and sequencing path. Do not add polling, a new browser request,
an extra message per movement, or a second trail-specific broadcast.
- Send player clients only the trail edges whose endpoints and passage are
inside their current authorized projection. Never serialize trail
coordinates outside the current visibility radius, and do not let the
browser retain or reconstruct hidden maze geometry from past projections.
- Send observers the complete authoritative trail together with their existing
full-map projection. Preserve observer read-only behavior and existing
snapshot chunk, message-size, queue, and backpressure bounds.
- Reconstruct the visible trail entirely in the frontend from each complete
server-approved projection. Prefer a fixed small number of batched SVG paths
or an equivalently bounded drawing layer rather than one DOM element per dot,
step, or historical event.
- Render a dotted yellow route for Player 1 and a dotted blue route for Player
2. When both players used the same passage, render an unmistakable mixed
yellow-and-blue dotted pattern on the same centerline rather than overwriting
one owner or blending them into an unrelated third color.
- Draw trails along traversable passage centers below players, keys, artifacts,
exits, doors, feedback, and controls but above the quiet floor. Trails must
not resemble walls, obscure objects, imply that a closed hedge is passable,
or expose a breakthrough until its opened edge is present in the same
authoritative projection.
- Keep current-player and other-player ownership understandable without color
alone by using a documented dot rhythm, outline, alternation, or equivalent
secondary cue consistent with the established player icon system. Shared
edges must remain recognizable in grayscale and at supported cell sizes.
- Preserve server-authoritative movement, fog of war, match results, explicit
session exit, reconnect behavior, compact controls, connection-quality
sampling, reduced-motion behavior, offline operation, Russian player-facing
copy, and firmware and LittleFS budgets.
### Deliverables
- Updated trail-state, visibility, protocol, lifecycle, ownership, layering,
and responsive-rendering contracts in `MVP.md`, `ARCHITECTURE.md`,
`docs/PROTOCOL.md`, `docs/UI_STYLE.md`, and `docs/PLAYER_UI.md`.
- A fixed-size authoritative two-player traversed-edge representation with
deterministic initialization, successful-move updates, reconnect retention,
and match-reset cleanup.
- A compact backward-safe view encoding that carries only role-authorized
trail ownership inside existing view chunks and broadcasts.
- A bounded player and observer trail renderer with yellow, blue, and mixed
two-color dotted treatments and no per-move accumulation of DOM nodes,
timers, animations, or client history.
- Deterministic game, protocol, visibility, serialization, browser, rendering,
lifecycle, reconnect, backpressure, size, and regression tests.
- Before-and-after maximum-session traffic, queue, heap, render-time,
compressed-web-asset, firmware, LittleFS, and representative physical-device
visual-verification evidence.
### Acceptance criteria
1. Every accepted authoritative in-maze move marks exactly one undirected edge
for the moving player's ownership. Ordinary passage moves and a successful
hedge breakthrough are marked; blocked, rejected, stale, duplicate,
rate-limited, and superseded attempts leave the trail unchanged.
2. Trail storage has a documented compile-time maximum derived from the bounded
maze cell count and consumes a fixed number of bits per possible canonical
edge and player. Repeated traversal, loops, backtracking, and arbitrarily long
matches cannot grow heap use or trail state.
3. Player 1 traversal produces the established yellow dotted treatment, Player
2 traversal produces the established blue dotted treatment, and traversal
of the same edge by both players produces one clearly mixed yellow-and-blue
pattern that preserves both ownership meanings without a third blended
color.
4. A player projection contains and renders trail ownership only for passages
fully inside that player's current server-approved visibility. It contains
no coordinates, ownership bits, inferred continuation, or retained drawing
outside the current projection, including edges previously visible to that
browser.
5. Each player sees both players' trails when those used passages are inside the
current permitted projection. The opponent trail cannot reveal the
opponent's current or historical route outside that projection.
6. An observer receives and renders the complete authoritative trails of both
players across the full maze, including shared edges, without gaining any
command authority or changing player projections.
7. Refreshing or reconnecting a player or observer during the same match
reconstructs the correct role-authorized trail from the fresh authoritative
view without browser-persisted trail history. Pause and resume do not add,
remove, duplicate, or reorder trail ownership.
8. A normal match reset, explicit player-leave cancellation, completed-match
restart, new maze, or device reboot clears all trail ownership before the
next match view. Session restoration alone never clears it.
9. Trail data is carried only in the existing view sequencing and chunking
flow. The implementation introduces no trail polling, new periodic request,
per-move trail message, additional broadcast pass, or independent retry
queue, and incomplete or stale view chunks never partially update a trail.
10. Every encoded view remains within the existing 512-byte WebSocket message
limit and published chunk-count and queue bounds. Deterministic worst-case
player and observer projections with every edge owned by both players
serialize successfully without hidden-state disclosure.
11. The frontend uses a documented fixed upper bound of drawing layers and DOM
nodes independent of move count. Projection replacement removes obsolete
geometry, and refresh, reconnect, reset, leave, result, and screen changes
leave no stale paths, timers, or retained hidden trail data.
12. Trails follow the center of actual open or server-opened passages and sit
below all players, objects, exits, hedge art, breakthrough effects, and
controls. They do not create overflow, cover interaction targets, resemble
walls, or make an intact hedge appear open.
13. Yellow, blue, and shared trail states remain distinguishable at 34, 40, and
46 CSS-pixel cells, in normal color and grayscale, and on supported narrow
portrait, compact-landscape, tablet, and observer layouts without relying
on sound, vibration, animation, or color alone.
14. Under the maximum supported two-player/four-observer load and a worst-case
fully marked maze, accepted movement latency, outgoing queue high-water,
dropped player messages, heap minima, observer responsiveness, and browser
rendering remain within the established Milestone 010 and Milestone 017
budgets with no material regression attributable to trail processing.
15. Automated game, protocol, browser, and host tests cover both owners, shared
edges, repeated traversal, loops, breakthroughs, rejected movement,
visibility clipping, observer completeness, chunk boundaries, worst-case
size, refresh, reconnect, pause, reset, explicit leave, stale views,
layering, cleanup, accessibility, and responsive layouts.
16. All host and browser tests pass; compressed-web-asset validation passes;
the payload remains within the 1.5 MiB web budget and LittleFS partition;
and firmware remains within the factory partition.
17. Physical-device testing confirms correct trail continuity during a complete
two-player match, refresh and reconnect reconstruction, mixed shared
passages, observer completeness, reset cleanup, and stable gameplay with no
perceptible input delay, rendering stutter, disconnect, or accumulated
resource use.
18. The project owner confirms on representative player and observer displays
that the dotted trails make used routes easy to follow, shared passages are
clearly attributed to both players, fog of war remains intact, and the map
stays readable and responsive, or explicitly approves a documented evidence
deviation.
### Evidence
- On 2026-09-13, the project owner required Player 1 and Player 2 routes to
remain visible until the end of the current match, survive refresh and
reconnect, and include only successful authoritative movement.
- The project owner required each player to see trail data only within the
current permitted visibility projection, while observers see both complete
trails, and selected a mixed two-color dotted treatment when both players use
the same passage.
- Planning analysis found that frontend-only state cannot consistently satisfy
cross-client, observer, refresh, and reconnect requirements without violating
fog of war. The milestone therefore limits ESP state to fixed-size per-edge
ownership bits, piggybacks authorized data on existing projections, and
delegates all trail drawing to a bounded frontend layer without additional
requests or broadcasts.
- Implementation and verification have not started, as requested by the
project owner.
- On 2026-09-13, implementation added a fixed 512-byte packed two-bit owner
mask for the at-most 2,048 canonical maze edges. Accepted in-maze movement,
including a completed hedge breakthrough, marks its canonical edge; rejected
and outward moves do not. Each existing view cell carries only authorized
east and south ownership masks, so no trail request, broadcast, or chunk type
was added. The browser replaces one non-interactive SVG layer per projection
with yellow, blue, and dual-rhythm shared dotted paths.
- On 2026-09-13, `bash scripts/run_host_tests.sh` passed all suites, including
authoritative shared-edge ownership and visibility coverage. The worst-case
observer serialization remained bounded at 88 chunks and 501 / 512 bytes.
`node --test test/web/test_player_app.js` passed 32 tests, including trail
mask validation and bounded rendering treatment. `python3
scripts/verify_web_assets.py` passed with deterministic payload
`245f402b654fdec986974d62d7f7cd3190fd79293a36c7c1e289200127d98f73` at
33,635 / 1,572,864 bytes. `/home/sasa/.platformio/penv/bin/pio run` passed
with firmware at 1,030,784 / 2,097,152 bytes, and `/home/sasa/.platformio/
penv/bin/pio run --target buildfs` passed at 2,031,616 / 2,031,616 bytes.
- On 2026-09-13, trail presentation was softened without changing protocol or
authority: stroke width is 0.065 cells, opacity is 58%, and both ownership
rhythms use shorter, more widely spaced dots. `node --test
test/web/test_player_app.js`, LittleFS build, and web-asset validation passed
with payload `5546c680b4d527815381735ec1e9016e02f0002efa865cb09e45b84e8f697cec`
at 33,638 / 1,572,864 bytes.
+6 -1
View File
@@ -22,9 +22,11 @@ extern "C" {
#define MAZE32_GAME_MAX_OBJECTS \
(6U + 2U * MAZE32_MAZE_MAX_LANDMARK_PAIRS + 2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS)
#define MAZE32_GAME_VIEW_CELLS_PER_CHUNK 12U
#define MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS 8U
#define MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS 5U
#define MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK 6U
#define MAZE32_GAME_VIEW_CHUNK_BYTES 512U
#define MAZE32_GAME_MAX_TRAIL_EDGES (2U * MAZE32_MAZE_MAX_CELLS)
#define MAZE32_GAME_TRAIL_BYTES ((MAZE32_GAME_MAX_TRAIL_EDGES + 3U) / 4U)
#define MAZE32_GAME_RAM_BUDGET_BYTES (40U * 1024U)
#define MAZE32_GAME_RUNTIME_BUDGET_MS 50U
@@ -163,6 +165,7 @@ typedef struct {
maze32_game_exit_t exits[MAZE32_GAME_PLAYER_COUNT];
uint8_t consumed_artifacts[MAZE32_GAME_MAX_ARTIFACTS];
uint8_t opened_passages[MAZE32_MAZE_MAX_CELLS];
uint8_t trail_owners[MAZE32_GAME_TRAIL_BYTES];
maze32_game_command_t commands[MAZE32_GAME_COMMAND_CAPACITY];
uint8_t command_head;
uint8_t command_count;
@@ -189,6 +192,8 @@ typedef struct {
uint16_t cell_index;
uint8_t passages;
uint8_t opened_passages;
uint8_t trail_east;
uint8_t trail_south;
} maze32_view_cell_t;
typedef struct {
+48 -2
View File
@@ -68,6 +68,36 @@ static int game_has_passage(const maze32_game_t* game, maze32_cell_t cell,
return (game_passages(game, cell) & passage) != 0U;
}
static uint16_t trail_edge_index(const maze32_maze_t* maze, maze32_cell_t cell,
maze32_passage_t passage) {
uint16_t horizontal_count = (uint16_t)(maze->config.height * (maze->config.width - 1U));
if (passage == MAZE32_PASSAGE_EAST) {
return (uint16_t)(cell.y * (maze->config.width - 1U) + cell.x);
}
return (uint16_t)(horizontal_count + cell.y * maze->config.width + cell.x);
}
static uint8_t trail_owner_mask(const maze32_game_t* game, uint16_t edge_index) {
uint8_t byte = game->trail_owners[edge_index / 4U];
return (uint8_t)((byte >> ((edge_index % 4U) * 2U)) & 0x03U);
}
static void mark_trail_edge(maze32_game_t* game, uint8_t player_index, maze32_cell_t origin,
maze32_game_direction_t direction) {
maze32_cell_t canonical = origin;
maze32_passage_t passage = direction_passage(direction);
if (direction == MAZE32_GAME_DIRECTION_NORTH) {
canonical.y--;
passage = MAZE32_PASSAGE_SOUTH;
} else if (direction == MAZE32_GAME_DIRECTION_WEST) {
canonical.x--;
passage = MAZE32_PASSAGE_EAST;
}
uint16_t edge_index = trail_edge_index(game->maze, canonical, passage);
uint8_t shift = (uint8_t)((edge_index % 4U) * 2U);
game->trail_owners[edge_index / 4U] |= (uint8_t)((1U << player_index) << shift);
}
static void reset_perseverance(maze32_game_player_t* player) {
player->perseverance_hits = 0U;
player->perseverance_last_impact_ms = 0U;
@@ -518,6 +548,7 @@ maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) {
}
finish_outward_move(game, player_index, command.submitted_ms, &processed);
} else {
maze32_cell_t origin = player->position;
maze32_passage_t passage = direction_passage(command.direction);
if (!game_has_passage(game, player->position, passage)) {
processed.breakthrough_hits = record_wall_impact(
@@ -544,6 +575,7 @@ maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) {
return processed;
}
player->position = moved_cell(player->position, command.direction);
mark_trail_edge(game, player_index, origin, command.direction);
accept_player_move(player, command.submitted_ms);
processed.moved = 1U;
resolve_cell_entry(game, player_index, &processed);
@@ -670,6 +702,20 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_
view->cells[view->cell_count].cell_index = index;
view->cells[view->cell_count].passages = game_passages(game, cell);
view->cells[view->cell_count].opened_passages = game->opened_passages[index];
if (x + 1U < game->maze->config.width) {
maze32_cell_t east = {x + 1U, y};
if (reveal_all || is_visible(center, east, radius)) {
view->cells[view->cell_count].trail_east = trail_owner_mask(
game, trail_edge_index(game->maze, cell, MAZE32_PASSAGE_EAST));
}
}
if (y + 1U < game->maze->config.height) {
maze32_cell_t south = {x, y + 1U};
if (reveal_all || is_visible(center, south, radius)) {
view->cells[view->cell_count].trail_south = trail_owner_mask(
game, trail_edge_index(game->maze, cell, MAZE32_PASSAGE_SOUTH));
}
}
view->cell_count++;
}
}
@@ -861,9 +907,9 @@ maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view,
}
for (size_t index = first; index < end; ++index) {
const maze32_view_cell_t* cell = &view->cells[index];
if (!append_text(output, output_size, &offset, "%s[%u,%u,%u]",
if (!append_text(output, output_size, &offset, "%s[%u,%u,%u,%u,%u]",
index == first ? "" : ",", cell->cell_index, cell->passages,
cell->opened_passages)) {
cell->opened_passages, cell->trail_east, cell->trail_south)) {
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
}
}
+4
View File
@@ -94,6 +94,10 @@ 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
+3 -1
View File
@@ -56,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
+3
View File
@@ -110,6 +110,9 @@ are not MVP requirements.
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 `Отклик`, `Связь`,
+36
View File
@@ -324,12 +324,48 @@ static void test_default_map_chunk_bounds(void) {
(unsigned int)chunks, (unsigned int)largest_chunk);
}
static void test_authoritative_trails_and_visibility(void) {
maze32_maze_t maze;
open_test_grid(&maze);
maze.starts[1] = (maze32_cell_t){2U, 1U};
maze32_game_t game = initialized_game(&maze);
maze32_game_set_phase(&game, MAZE32_GAME_PHASE_ACTIVE);
uint32_t sequence = 0U;
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST,
200U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_ACCEPTED);
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_WEST,
200U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_ACCEPTED);
maze32_view_t observer;
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &observer) ==
MAZE32_GAME_RESULT_ACCEPTED);
uint16_t origin = index_of(1U, 1U);
int found = 0;
for (uint16_t index = 0U; index < observer.cell_count; ++index) {
if (observer.cells[index].cell_index == origin) {
assert(observer.cells[index].trail_east == 3U);
found = 1;
}
}
assert(found);
maze32_view_t player;
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &player) ==
MAZE32_GAME_RESULT_ACCEPTED);
for (uint16_t index = 0U; index < player.cell_count; ++index) {
assert(player.cells[index].trail_east <= 3U && player.cells[index].trail_south <= 3U);
}
puts("authoritative trail ownership and visibility: PASS");
}
int main(void) {
test_movement_cooldown_and_ordering();
test_bounded_queue();
test_shared_cell_meeting();
test_visibility_and_serialization();
test_default_map_chunk_bounds();
test_authoritative_trails_and_visibility();
puts("game core tests: PASS");
return 0;
}
+14
View File
@@ -507,3 +507,17 @@ test("finished-player exit returns remaining browsers to the waiting lobby", ()
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/);
});
+4
View File
@@ -123,6 +123,10 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.maze { position: absolute; left: 50%; top: 50%; display: grid; transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px))); }
.cell { position: relative; width: var(--cell); height: var(--cell); background: #11131a; }
.cell::after { content: ""; position: absolute; inset: 7px; background: #1c1b27; opacity: .55; }
.trail-layer { position: absolute; z-index: 0; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.trail-path { fill: none; stroke-width: .065; stroke-linecap: round; opacity: .58; }
.trail-player-one { stroke: var(--p1); stroke-dasharray: .06 .20; }
.trail-player-two { stroke: var(--p2); stroke-dasharray: .03 .16; stroke-dashoffset: .08; }
.hedge-layer { position: absolute; z-index: 1; inset: 0; pointer-events: none; overflow: visible; }
.hedge-edge { position: absolute; width: var(--cell); height: calc(var(--cell) * .2); left: calc(var(--hedge-x) * var(--cell)); top: calc(var(--hedge-y) * var(--cell) - var(--cell) * .1); transform-origin: 50% 50%; pointer-events: none; }
.hedge-edge svg { display: block; width: 100%; height: 100%; overflow: visible; }
+31 -1
View File
@@ -306,7 +306,7 @@
return false;
}
const cellLimit = snapshot.width * snapshot.height;
return snapshot.cells.every(cell => Array.isArray(cell) && cell.length === 3 && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 15 && Number.isInteger(cell[2]) && cell[2] >= 0 && cell[2] <= 15) && snapshot.objects.every(object => Array.isArray(object) && object.length === 4 && Number.isInteger(object[3]) && object[3] >= 0 && object[3] < cellLimit);
return snapshot.cells.every(cell => Array.isArray(cell) && (cell.length === 3 || cell.length === 5) && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 15 && Number.isInteger(cell[2]) && cell[2] >= 0 && cell[2] <= 15 && (cell.length === 3 || (Number.isInteger(cell[3]) && cell[3] >= 0 && cell[3] <= 3 && Number.isInteger(cell[4]) && cell[4] >= 0 && cell[4] <= 3))) && snapshot.objects.every(object => Array.isArray(object) && object.length === 4 && Number.isInteger(object[3]) && object[3] >= 0 && object[3] < cellLimit);
}
class SnapshotAssembler {
@@ -781,6 +781,34 @@
return slot === 2 ? {name: "Синий герой", keyName: "синего игрока", owner: "two"} : {name: "Жёлтый герой", keyName: "жёлтого игрока", owner: "one"};
}
function renderTrails(maze, cells, width, minX, minY, columns, rows) {
const layer = documentObject.createElementNS("http://www.w3.org/2000/svg", "svg");
layer.classList.add("trail-layer");
layer.setAttribute("viewBox", `0 0 ${columns} ${rows}`);
layer.setAttribute("preserveAspectRatio", "none");
layer.setAttribute("aria-hidden", "true");
const paths = [[], []];
cells.forEach(cell => {
if (!Array.isArray(cell) || cell.length !== 5) return;
const x = cell[0] % width - minX;
const y = Math.floor(cell[0] / width) - minY;
[[cell[3], x + 1, y], [cell[4], x, y + 1]].forEach(([owners, endX, endY]) => {
if (!owners) return;
const segment = `M ${x + .5} ${y + .5} L ${endX + .5} ${endY + .5}`;
if (owners & 1) paths[0].push(segment);
if (owners & 2) paths[1].push(segment);
});
});
paths.forEach((segments, owner) => {
if (!segments.length) return;
const path = documentObject.createElementNS("http://www.w3.org/2000/svg", "path");
path.classList.add("trail-path", owner === 0 ? "trail-player-one" : "trail-player-two");
path.setAttribute("d", segments.join(" "));
layer.appendChild(path);
});
maze.appendChild(layer);
}
function renderMap(snapshot) {
const maze = byId("maze");
maze.replaceChildren();
@@ -816,6 +844,7 @@
if (data.index === snapshot.opponent) addPawn(cell, slot === 1 ? 2 : 1, true);
maze.appendChild(cell);
});
renderTrails(maze, snapshot.cells, snapshot.width, minX, minY, columns, rows);
renderHedgeEdges(maze, snapshot.cells, snapshot.width, minX, minY);
byId("map-placeholder").hidden = true;
}
@@ -900,6 +929,7 @@
});
maze.appendChild(cell);
});
renderTrails(maze, snapshot.cells, snapshot.width, 0, 0, snapshot.width, snapshot.height);
renderHedgeEdges(maze, snapshot.cells, snapshot.width, 0, 0);
players.forEach((player, index) => {
const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`;