feat(web): render canonical living hedge maze walls

This commit is contained in:
2026-09-11 23:46:02 +03:00
parent 9ce44cdef7
commit 07eaed9c93
10 changed files with 480 additions and 32 deletions
+5
View File
@@ -342,6 +342,11 @@ bits, and projects changed passages only in cells already visible to that role.
The browser may correlate a matching server-approved impact stage with an
already visible bush wall; it never derives or opens topology locally.
Browser hedge rendering derives a bounded canonical edge list solely from the
current role projection. Its deterministic local SVG selection is presentation
only: it cannot create maze edges, alter hit testing, retain hidden cells, or
change authoritative movement and visibility.
Player projections are translated only within their received cells so the
authoritative `self` cell aligns with the responsive local focal point. That
point is centered horizontally and 30% down the usable portrait map region;
+260
View File
@@ -2591,3 +2591,263 @@ The following planning-time request defines this milestone:
breach variants, and the density and palette defined in `docs/UI_STYLE.md`.
This satisfies the required owner review. Runtime integration remains a
separately planned milestone.
## Milestone 024 — Runtime living hedge rendering
**Status:** `IN_PROGRESS`
### Objective
Replace the maze's simple green wall strokes and provisional perseverance
damage styling with the project-owner-approved Milestone 023 living-hedge SVG
family, so intact walls, impacts, and permanent breakthroughs form one varied,
legible top-down environment on player and observer screens without changing
authoritative gameplay or exposing hidden state.
### Scope
- Do not start this milestone until every preceding milestone is `COMPLETE`.
Use only the intact families, impact sequence, breakthrough frame, breached
variants, density, and palette approved in Milestone 023. Any substantive art
redesign requires returning to a separately planned sample-review milestone.
- Reconcile `docs/UI_STYLE.md`, `docs/PLAYER_UI.md`, and `ARCHITECTURE.md` with
the runtime hedge-rendering contract before changing browser assets.
- Derive a compact runtime SVG sprite from
`docs/hedge-sample-library.svg`; keep the review source untouched and do not
make runtime pages depend on files under `docs/`. Preserve stable semantic
symbol identifiers where practical and remove comparison-sheet-only content.
- Package the runtime sprite locally with the compressed web application. Do
not add remote images, fonts, libraries, network requests, embedded raster
data, or runtime SVG scripts.
- Replace the four solid CSS cell borders with a dedicated decorative edge
layer that draws each server-provided closed wall once. Canonicalize shared
edges so two adjacent visible cells do not produce doubled foliage, darker
overlaps, seams, or inconsistent variants.
- Draw walls at projection boundaries only from the current server-provided
cell and passage bits. Do not assume what lies beyond an unseen edge, fill
unknown space, retain an explored map, or generate decorative geometry for
cells absent from the current projection.
- Select among the six approved intact variants with a documented deterministic
function of the currently visible canonical wall edge and orientation. The
same edge must keep its variant across rerenders and appear consistently to
both players and observers when their approved projections include it, with
no new protocol or authoritative game-state field.
- Rotate or mirror approved symbols for north, east, south, and west while
preserving their shared-edge center, non-directional lighting, foliage
thickness, and joins. Compose approved corner, crossing, and end treatments
only from wall edges explicitly present in the received projection.
- Preserve exact maze geometry and hit testing. Decorative foliage may extend
visually into neighboring cells by a bounded amount, but it cannot change
traversability, pointer targets, player focal-point calculation, map bounds,
projection translation, or page overflow.
- Keep hedge artwork behind players, keys, doors, artifacts, landmarks, and
interaction feedback. Leaves and branches must remain recognizable at 34,
40, and 46 CSS-pixel cells without obscuring the passage center or reducing
object and player contrast.
- Connect Milestone 022 server-approved impact stages one through four to the
matching approved static SVG frames on the exact visible wall that received
the impact. Preserve the existing bounded timer, event correlation,
deduplication, and cleanup behavior; the browser never increments damage or
predicts a breakthrough locally.
- Present the approved breakthrough frame only when justified by the matching
authoritative response and transition to one of the approved permanent
breached variants when the received `opened` passage state changes. Derive
this transition only from current and immediately preceding visible
server-approved state, held in bounded memory and discarded on projection
replacement or reset.
- Render one canonical permanent breach decoration for a shared opened edge so
the gap aligns from both adjacent cells and from both traversal directions.
Breached foliage must remain visible while the opened edge is in the current
projection and disappear when that edge is no longer projected.
- Preserve reduced-motion behavior by using the approved static impact frames
and an immediate permanent breach state, with no shaking, swaying, moving
leaves, or travel animation. Normal motion may use restrained transforms of
the approved shapes but must not allocate unbounded particles or DOM nodes.
- Use bounded reusable DOM or CSS structures. A rerender must have a documented
maximum number of hedge elements derived from the maximum visible projection,
remove obsolete edges, and never accumulate duplicate SVG instances,
animation timers, styles, or event listeners.
- Apply the same hedge family to the observer's full-map renderer without
changing its read-only behavior, pan or zoom controls, minimum viewport, or
authoritative snapshot contents. At overview zoom levels, simplify details
gracefully while retaining continuous wall and breached-gap silhouettes.
- Preserve Russian player-facing copy, Milestone 020 visual feedback,
Milestone 021 focal-point positioning, Milestone 022 game rules, offline
operation, accessibility, and existing firmware and filesystem budgets.
### Deliverables
- Updated runtime hedge-rendering, layering, variation, projection-safety, and
reduced-motion contracts in canonical and supporting documentation.
- Optimized local runtime SVG sprite derived reproducibly from the approved
Milestone 023 source, with deterministic size and dependency validation.
- A deduplicated decorative edge renderer for player and observer maps using
the six intact hedge variants and approved joins and end treatments.
- Runtime wiring for the four approved impact stages, breakthrough frame, and
three deterministic permanent breached variants.
- Deterministic browser tests, asset checks, host regression results, size
evidence, and representative mobile, desktop, observer, and physical-device
visual verification.
### Acceptance criteria
1. Every closed edge in the received player or observer projection is rendered
with an approved living-hedge SVG variant rather than a solid CSS green
border. Visible leaves, twigs, irregular foliage, depth tones, and outline
remain recognizable at supported gameplay scales. Horizontal and vertical
hedge center lines coincide with their authoritative cell edges, never pass
through cell centers, and leave open floor at least as clear as the former
solid green wall presentation.
2. Every visible shared wall has exactly one canonical decorative edge. Adjacent
cells, full rerenders, projection updates, and observer zoom changes produce
no doubled foliage, dark overlap, seam, flicker, or mismatched variant.
3. The documented deterministic selection maps canonical visible wall edges to
all six approved intact variants with representative distribution. The same
edge and orientation produce the same variant across rerenders and roles,
while no random state, hidden topology, or new protocol field is required.
4. North, east, south, and west walls plus continuous runs, corners, crossings,
exposed ends, and projection boundaries align to within one CSS pixel at 34,
40, and 46 CSS-pixel cells and at representative device pixel ratios.
5. The edge layer is derived exclusively from cells and passage/opened bits in
the current server-approved projection. Tests confirm no unknown cell,
missing edge, hidden route, remote breach, or explored-map decoration is
inferred, retained, serialized, or rendered.
6. Hedge artwork does not alter authoritative movement, wall collision,
visibility, object pickup, exits, results, input hit targets, observer
read-only behavior, or player focal positioning. No gameplay or protocol
contract changes are introduced.
7. Players, keys, exits, artifacts, landmarks, controls, and feedback remain
above the hedge layer and clearly legible in normal color, grayscale, and
representative color-vision checks. Decorative overhang creates no page or
map-shell overflow and captures no pointer input.
8. Each server-approved perseverance impact selects the matching approved stage
on the exact currently visible wall. Stages do not advance from local input,
duplicate or reordered messages, impacts on another edge, or events outside
the current projection.
9. A matching fifth-impact transition briefly uses the approved breakthrough
frame in normal-motion mode and settles on one approved permanent breached
variant from the next authoritative opened-edge projection. The visual gap
aligns bidirectionally and cannot be mistaken for a closed wall.
10. All three approved breached variants appear through a stable deterministic
mapping. They remain visible only while their authoritative opened edge is
projected, survive ordinary rerenders without changing variant, and leave
no stale decoration after reset, match end, disconnect, or projection loss.
11. Reduced-motion mode uses static impact stages and changes immediately to the
permanent breach without maze shake, foliage sway, moving leaf fragments,
or breakthrough travel animation. Meaning does not depend on motion, sound,
vibration, text, or green hue alone.
12. The renderer has a tested fixed upper bound for hedge elements, transient
effects, timers, and memory based on the maximum supported projection.
Repeated snapshots and impacts do not accumulate nodes, listeners, styles,
or timers, and obsolete edge elements are removed deterministically.
13. Observer rendering uses the same approved family and canonical edge logic,
keeps full-map pan and zoom responsive, and preserves continuous intact and
breached silhouettes at overview and inspection scales.
14. Automated browser tests cover variant selection, shared-edge deduplication,
every orientation and join, projection boundaries, hidden-state isolation,
layering, impact and breach transitions, cleanup, reduced motion, observer
zoom, supported cell sizes, and zero viewport overflow.
15. The runtime SVG is local, valid, script-free, raster-free, deterministically
generated, and included in web-asset reproducibility checks. All host tests
pass, compressed web assets remain within the 1.5 MiB budget and LittleFS
partition, and firmware remains within the factory partition.
16. The project owner confirms on representative mobile and observer screens
that the maze resembles a varied living hedge from above, repetition is not
distracting, leaves and branches are visible, objects remain easy to find,
impacts visibly damage the correct bush, and permanent openings are clear,
or explicitly approves a documented evidence deviation.
### Evidence
The following planning-time state defines this milestone:
- On 2026-09-10, the project owner requested a separate future milestone to
integrate the living-hedge samples generated and approved by Milestone 023.
The approved review source contains six intact variants, four impact stages,
one breakthrough frame, three permanent breach variants, and topology
examples, but remains outside runtime assets at planning time.
- Runtime SVG derivation, browser integration, tests, resource measurements,
physical-device verification, and owner acceptance are deferred to this
milestone. No implementation was performed while creating the plan.
- 2026-09-11 implementation evidence: the local `web/hedges.svg` runtime
sprite contains the approved intact, impact, breakthrough, and breach
symbols. Player and observer maps derive a bounded canonical hedge-edge list
only from their current projections; a full 32 by 32 projection is bounded
to 2,112 edge elements. The renderer uses deterministic edge hashes and one
restartable breakthrough timer, with immediate reduced-motion settlement.
- 2026-09-11 verification: `bash scripts/run_host_tests.sh` passed. `node
--test test/web/test_player_app.js test/web/test_hedge_svg.js` passed all 26
browser tests. `python3 scripts/verify_web_assets.py` passed with a 29,912 /
1,572,864 byte compressed payload.
- 2026-09-11 skipped check: `pio run` could not acquire
`/home/sasa/.platformio/platforms.lock` because the global PlatformIO home
is read-only. Firmware size verification, physical-device visual testing,
and project-owner mobile and observer acceptance remain required. Milestone
024 therefore remains `IN_PROGRESS`.
- 2026-09-11 user-run verification: `./scripts/run_host_tests.sh` passed all
host and bundled player browser checks; `node --test
test/web/test_player_app.js test/web/test_hedge_svg.js` passed all 26 tests;
and `python3 ./scripts/verify_web_assets.py` confirmed deterministic assets
at 29,912 / 1,572,864 bytes.
- 2026-09-11 physical-device diagnostic: host `192.168.1.103/24` reached the
connected ESP at `192.168.1.50` with two ICMP replies and no packet loss, but
`curl --noproxy '*' http://192.168.1.50/health` was refused on TCP port 80.
The device is reachable but its HTTP server is not listening; no flash or
reset was performed. Physical visual acceptance remains pending.
- 2026-09-11 physical-device repair and verification: boot logs identified
`ESP_ERR_HTTPD_HANDLERS_FULL` after adding the hedge sprite route. The
bounded URI handler capacity was raised from 13 to 14. A writable temporary
PlatformIO home built and uploaded the application and LittleFS image through
`/dev/ttyACM0` to the ESP32-C6FH4; every upload hash was verified. Firmware
uses 1,029,600 / 2,097,152 factory bytes (49.1% flash) and 59,940 / 327,680
bytes RAM (18.3%); LittleFS uses 2,031,616 / 2,031,616 bytes. After reboot,
`GET /health` returned `200` with `{"status":"serving","healthy":true,`
`"version":"0.14.0","errorCode":0}`, while GET requests for `/` and
`/hedges.svg` both returned `200` (3,378 and 1,106 bytes respectively).
Project-owner visual acceptance of the mobile and observer hedge rendering
remains required before the milestone can become `COMPLETE`.
- 2026-09-11 project-owner mobile review rejected the first runtime hedge
presentation because foliage obscured the wall boundaries and made open
routes substantially harder to understand than the former green strokes.
The corrective contract limits foliage to 20% of a cell, adds a continuous
high-contrast spine only to closed edges, and raises the contrast of the
inset cell floor. Repeat physical-device visual acceptance remains required.
- 2026-09-11 corrective verification: a local rendered maze preview confirmed
that the continuous spine restores exact closed-edge continuity while opened
edges remain uninterrupted passages. `node --test
test/web/test_player_app.js test/web/test_hedge_svg.js` passed all 26 tests,
`bash scripts/run_host_tests.sh` passed, and web-asset reproducibility passed
at 30,012 / 1,572,864 compressed bytes. A clean PlatformIO build passed with
firmware at 1,029,600 / 2,097,152 bytes and RAM at 59,940 / 327,680 bytes;
`pio run -t buildfs` also passed for the 2,031,616-byte LittleFS image.
- 2026-09-11 deployment was not attempted because no `/dev/ttyACM*` or
`/dev/ttyUSB*` device was present and the ESP HTTP endpoint at
`192.168.1.50:80` was unreachable from the host. The already-open browser tab
still shows its previously loaded assets. Upload and repeat project-owner
mobile visual acceptance remain required, so Milestone 024 stays
`IN_PROGRESS`.
- 2026-09-11 repeat project-owner review rejected the first readability
correction: its added bright spine and emphasized floor insets produced a
confusing rectangular grid, while the player still appeared to stand on a
hedge. Browser geometry inspection found two root causes: rotating a
horizontal SVG wrapper into a vertical edge left its center displaced by
half a cell on both axes, and SVG instances had no `160 x 40` view box, so
source-coordinate foliage overflowed far beyond its owning cell edge. The
next correction removes the spine and floor emphasis, restores the quiet cell
surface, constrains every SVG to one edge, and aligns vertical wrapper centers
with authoritative boundaries. Repeat physical-device visual acceptance
remains required.
- 2026-09-11 second corrective verification: a rendered 7 by 7 maze at the
minimum gameplay cell scale showed each `160 x 40` hedge constrained to one
edge, vertical and horizontal centers aligned, the player wholly inside its
cell, and continuous open-floor routes without the rejected rectangular
overlay. `bash scripts/run_host_tests.sh` passed, including all bundled
browser checks, and web-asset reproducibility passed at 29,979 / 1,572,864
compressed bytes. A clean PlatformIO build passed with firmware at
1,029,600 / 2,097,152 bytes and RAM at 59,940 / 327,680 bytes; the configured
2,031,616-byte LittleFS image also built successfully.
- 2026-09-11 the corrected image could not be uploaded from this environment:
no `/dev/ttyACM*` or `/dev/ttyUSB*` device was present and the ESP HTTP
endpoint remained unreachable from the host. The physical page still needs
the new filesystem image and repeat project-owner visual acceptance, so
Milestone 024 remains `IN_PROGRESS`.
+11 -1
View File
@@ -20,6 +20,7 @@ static const maze32_static_asset_t PLAYER_STYLE_ASSET = {"/littlefs/player.css.g
static const maze32_static_asset_t PLAYER_SCRIPT_ASSET = {"/littlefs/player.js.gz",
"text/javascript; charset=utf-8"};
static const maze32_static_asset_t ICON_ASSET = {"/littlefs/icons.svg.gz", "image/svg+xml"};
static const maze32_static_asset_t HEDGE_ASSET = {"/littlefs/hedges.svg.gz", "image/svg+xml"};
static void preserve_static_context(void* context) {
(void)context;
@@ -104,7 +105,7 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
}
httpd_config_t configuration = HTTPD_DEFAULT_CONFIG();
configuration.max_uri_handlers = 13;
configuration.max_uri_handlers = 14;
configuration.max_open_sockets = 9;
configuration.lru_purge_enable = false;
configuration.recv_wait_timeout = 5;
@@ -148,6 +149,12 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
.handler = send_static_asset,
.user_ctx = (void*)&ICON_ASSET,
};
const httpd_uri_t hedges = {
.uri = "/hedges.svg",
.method = HTTP_GET,
.handler = send_static_asset,
.user_ctx = (void*)&HEDGE_ASSET,
};
const httpd_uri_t health_uri = {
.uri = "/health",
.method = HTTP_GET,
@@ -174,6 +181,9 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
if (result == ESP_OK) {
result = httpd_register_uri_handler(*server, &icons);
}
if (result == ESP_OK) {
result = httpd_register_uri_handler(*server, &hedges);
}
if (result == ESP_OK) {
result = httpd_register_uri_handler(*server, &health_uri);
}
+5
View File
@@ -63,6 +63,11 @@ blocked-move text. Opened-passage bits in an ordinary cell projection render as
a permanent trampled bush gap. Effects have one restartable timer and clear on
pause, result, reset, disconnect, and control reset. Reduced motion is static.
The map draws only a bounded canonical hedge-edge layer generated from current
received cells. It hashes each visible edge deterministically to choose the
approved local SVG variant, so the browser neither retains unseen geometry nor
decides topology. The layer is decorative and cannot receive input.
All remaining Russian system messages use one `role=status` live message rail
above the application top bar. One connection-loss message has highest priority
and remains until a successful recovery; other messages deduplicate, replace
+29
View File
@@ -158,6 +158,35 @@ are not MVP requirements.
at 34, 40, and 46 CSS pixels. They contain no coordinates, objects,
ownership, hidden topology, animation, or runtime dependencies.
### Runtime hedge rendering
- The local `/hedges.svg` sprite is derived from the approved review source.
It contains only reusable runtime symbols and no scripts, raster data, or
external dependencies. The review sheet in `docs/` is never requested by a
browser page.
- A map uses one non-interactive hedge-edge element for each canonical closed
or server-opened edge in its current projection. Closed shared edges are
owned by their north or west cell; east and south edges are rendered only at
the current projection boundary. This bounds a full 32 by 32 map to 2,112
hedge elements and avoids duplicated foliage.
- A stable integer hash of the canonical edge index and orientation selects one
of the six approved intact variants. Opened edges select one of the three
approved breach variants. The same projection edge always receives the same
art without random state, retained exploration, or a protocol field.
- Gameplay readability takes priority over foliage density. Hedge foliage is
at most 20% of one cell, and the center line of every horizontal or vertical
symbol must coincide with its authoritative cell edge. Rotation must preserve
that center line instead of shifting a vertical hedge through cell centers.
Every runtime SVG instance uses the approved `160 x 40` view box scaled to
exactly one cell edge; source-coordinate artwork must never overflow into
unrelated cells. Cell interiors remain visually quiet and continuous so
passages read as open floor rather than as a grid of highlighted rectangles.
- Hedge edges are decorative, have `pointer-events: none`, sit below map
objects and players, and may overhang an edge by at most 15% of a cell. A
projection replacement removes all obsolete edge nodes. One restartable
breakthrough timer may briefly show the approved breakthrough frame; reduced
motion settles immediately on the permanent breach.
## 8. Responsive behavior
- The minimum supported player viewport is `412 x 916` CSS pixels.
+18
View File
@@ -6,6 +6,8 @@ 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"/);
@@ -25,3 +27,19 @@ test("hedge review sheet is local editable SVG with complete stable symbols", ()
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);
});
+22 -1
View File
@@ -167,7 +167,15 @@ test("playful feedback uses bounded maze-only effects and a top message rail", (
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, /\.cell\.trampled-n::before/);
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, /Там стена\. Очень убедительная\.|Слишком быстро! Сервер просит немного меньше турбо\./);
});
@@ -260,6 +268,19 @@ test("chunk assembly replaces projection cells without explored-map memory", ()
assert.equal(player.snapshotIsValid({width: 32, height: 33, cells: [], objects: []}), false);
});
test("hedge edges are canonical, bounded, and deterministic", () => {
const cells = [[0, 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]];
const first = player.projectedHedgeEdges(cells, 2);
const second = player.projectedHedgeEdges(cells, 2);
assert.deepEqual(first, second);
assert.equal(new Set(first.map(edge => edge.key)).size, first.length);
assert.ok(first.length <= cells.length * 2 + 2 * 2);
assert.equal(player.hedgeVariant(first[0], 6), player.hedgeVariant(first[0], 6));
const opened = player.projectedHedgeEdges([[0, 0, 2], [1, 0, 8]], 2);
assert.equal(opened.filter(edge => edge.opened).length, 1);
assert.equal(player.hedgeKeyForMove(0, 2, "east"), "v:1:0");
});
test("refresh contract restores only the token and waits for server projection", () => {
const restore = player.createEnvelope("restore", 1, "restore-1", {token: "0123456789abcdef0123456789abcdef"});
assert.deepEqual(Object.keys(restore.payload), ["token"]);
+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 40" aria-hidden="true">
<defs>
<g id="leaf-cluster-a" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round"><path fill="#296443" d="M2 13C0 6 7 0 13 3c2-7 12-5 14 2 8-1 11 8 5 13 3 8-6 13-12 8-6 5-14 1-13-6-7 0-9-5-5-7Z"/><path fill="#4f9b55" d="M7 13c2-5 7-8 12-7 5 0 8 4 9 8-3 5-8 8-14 7-5 0-8-3-7-8Z"/><path fill="#c7e68a" d="M12 9c3-3 8-3 10 0-2 3-7 4-10 0Z"/></g>
<g id="leaf-cluster-b" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round"><path fill="#296443" d="M1 16C-1 8 5 2 11 4c1-7 11-7 14-1 7-3 13 5 8 11 5 6-2 14-9 11-4 7-15 4-15-3-8 2-12-4-8-6Z"/><path fill="#8cc968" d="M8 14c2-5 8-7 13-5 4 2 6 7 4 11-5 4-13 3-17-2Z"/></g>
<g id="twig-fork" fill="none" stroke="#765033" stroke-width="2" stroke-linecap="round"><path d="M3 20 16 8 29 3M16 8l2 12M16 8 7 4"/></g>
<g id="hedge-base"><path d="M0 11C9 2 20 4 30 9c12-8 23-7 34 1 10-5 22-5 32 1 11-7 23-6 34 1 9-5 22-4 30 4v17c-10 5-22 5-32 0-11 7-23 6-34 0-10 6-22 6-32 0-11 6-23 6-34 0-10 5-20 4-30-3Z" fill="#173d2a"/><use href="#twig-fork" x="5" y="9"/><use href="#twig-fork" x="54" y="8"/><use href="#twig-fork" x="105" y="9"/><use href="#leaf-cluster-a" x="1" y="5"/><use href="#leaf-cluster-b" x="27" y="4"/><use href="#leaf-cluster-a" x="54" y="6"/><use href="#leaf-cluster-b" x="80" y="4"/><use href="#leaf-cluster-a" x="107" y="5"/><use href="#leaf-cluster-b" x="132" y="5"/></g>
<g id="hedge-intact-1"><use href="#hedge-base"/></g>
<g id="hedge-intact-2"><use href="#hedge-base"/><path fill="#8cc968" d="M20 7l8-3 4 4-8 4Zm61 3 8-4 4 4-7 4Zm48 12 7-3 3 5-7 3Z"/></g>
<g id="hedge-intact-3"><use href="#hedge-base"/><path fill="none" stroke="#765033" stroke-width="2" d="M38 27 49 9m36 18L73 7m47 20 13-17"/></g>
<g id="hedge-intact-4"><use href="#hedge-base"/><path fill="#4f9b55" d="M6 27c12-5 20 6 31 0 11-6 20 6 32 0 11-6 21 5 33-1 10-5 19 3 29 0v8H6Z"/></g>
<g id="hedge-intact-5"><use href="#hedge-base"/><path fill="#c7e68a" d="m10 17 5-4 4 3-5 4Zm42-8 5-4 4 3-5 4Zm46 11 5-4 4 3-5 4Zm38-8 5-4 4 3-5 4Z"/></g>
<g id="hedge-intact-6"><use href="#hedge-base"/><path fill="none" stroke="#173d2a" stroke-width="2" d="M19 30 32 14l14 13m33 3 12-15 13 14m17 1 11-12 12 10"/></g>
<g id="hedge-impact-1"><use href="#hedge-intact-1"/><path fill="#8cc968" d="m70 2 8 3-3 6-8-3Zm12 28 7 2-3 5-7-2Z"/></g>
<g id="hedge-impact-2"><use href="#hedge-intact-1"/><path fill="#173d2a" d="M64 0h30v40H64Z" opacity=".18"/></g>
<g id="hedge-impact-3"><use href="#hedge-intact-1" transform="skewX(-4)"/><path fill="none" stroke="#765033" stroke-width="3" d="m71 28 11-18m1 16 11-13"/></g>
<g id="hedge-impact-4"><use href="#hedge-intact-1" transform="translate(0 4) scale(1 .86)"/><path fill="#c7e68a" d="m61 2 8 3-3 6-8-3Zm13 7 8 3-3 6-8-3Zm14-4 8 3-3 6-8-3Z"/></g>
<g id="hedge-breakthrough"><use href="#hedge-intact-1"/><path fill="#11131a" stroke="#173d2a" stroke-width="2" d="M62 0h36v40H62Z"/><path fill="#765033" d="m60 6 10 8-4 5-11-8Zm40 12 10-8 5 5-10 9Z"/></g>
<g id="hedge-breach-1"><use href="#hedge-breakthrough"/><path fill="#4f9b55" d="M44 28c8-7 14-5 18 0v8H42Zm56 0c7-6 13-5 18 0v8H98Z"/></g>
<g id="hedge-breach-2"><use href="#hedge-breakthrough"/><path fill="#296443" d="M35 25c11-8 18-4 27 3v10H33Zm65 3c10-7 18-11 27-3v13H98Z"/></g>
<g id="hedge-breach-3"><use href="#hedge-breakthrough"/><path fill="none" stroke="#765033" stroke-width="3" d="m38 33 17-12m50 12 17-12"/></g>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

+8 -11
View File
@@ -112,17 +112,12 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.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(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; 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); }
.cell { position: relative; width: var(--cell); height: var(--cell); background: #11131a; }
.cell::after { content: ""; position: absolute; inset: 7px; background: #1c1b27; opacity: .55; }
.cell.trampled-n::before, .cell.trampled-e::before, .cell.trampled-s::before, .cell.trampled-w::before { content: ""; position: absolute; z-index: 2; background: repeating-linear-gradient(90deg, #254d2d 0 4px, #6f8d43 4px 7px); opacity: .9; }
.cell.trampled-n::before { left: 4px; right: 4px; top: -3px; height: 6px; }
.cell.trampled-e::before { top: 4px; right: -3px; bottom: 4px; width: 6px; }
.cell.trampled-s::before { right: 4px; bottom: -3px; left: 4px; height: 6px; }
.cell.trampled-w::before { top: 4px; bottom: 4px; left: -3px; width: 6px; }
.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); }
@@ -234,8 +229,10 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.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; }
+100 -19
View File
@@ -302,6 +302,48 @@
return snapshot && Array.isArray(snapshot.cells) ? snapshot.cells.map(cell => cell[0]) : [];
}
function hedgeEdgeKey(x, y, vertical) {
return `${vertical ? "v" : "h"}:${x}:${y}`;
}
function hedgeVariant(edge, count) {
const seed = ((edge.x + 1) * 1103515245 + (edge.y + 1) * 12345 + (edge.vertical ? 97 : 13)) >>> 0;
return seed % count + 1;
}
function projectedHedgeEdges(cells, width) {
if (!Array.isArray(cells) || !Number.isInteger(width) || width < 1) return [];
const present = new Set(cells.map(cell => cell[0]));
const edges = new Map();
const add = (x, y, vertical, opened) => {
const key = hedgeEdgeKey(x, y, vertical);
const existing = edges.get(key);
edges.set(key, {key, x, y, vertical, opened: !!opened || !!(existing && existing.opened)});
};
cells.forEach(cell => {
if (!Array.isArray(cell) || cell.length < 3 || !Number.isInteger(cell[0])) return;
const index = cell[0]; const passages = cell[1]; const opened = cell[2];
const x = index % width; const y = Math.floor(index / width);
const eastVisible = present.has(index + 1) && (index + 1) % width !== 0;
const southVisible = present.has(index + width);
if (!(passages & 1) || (opened & 1)) add(x, y, false, opened & 1);
if (!(passages & 8) || (opened & 8)) add(x, y, true, opened & 8);
if ((!eastVisible && !(passages & 2)) || (opened & 2)) add(x + 1, y, true, opened & 2);
if ((!southVisible && !(passages & 4)) || (opened & 4)) add(x, y + 1, false, opened & 4);
});
return Array.from(edges.values());
}
function hedgeKeyForMove(cellIndex, width, direction) {
if (!Number.isInteger(cellIndex) || !Number.isInteger(width) || width < 1) return null;
const x = cellIndex % width; const y = Math.floor(cellIndex / width);
if (direction === "north") return hedgeEdgeKey(x, y, false);
if (direction === "east") return hedgeEdgeKey(x + 1, y, true);
if (direction === "south") return hedgeEdgeKey(x, y + 1, false);
if (direction === "west") return hedgeEdgeKey(x, y, true);
return null;
}
function clampObserverZoom(value) {
return Math.min(6, Math.max(.25, value));
}
@@ -313,7 +355,7 @@
function boot(documentObject, windowObject) {
const byId = id => documentObject.getElementById(id);
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null}, moveDirections: new Map(), touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, startX: null, startY: null, direction: null, repeatTimer: null, repeatMs: null}, dpad: {pointerId: null, direction: null, repeatTimer: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null, breakthroughTimer: null}, hedge: {openedKeys: new Set()}, moveDirections: new Map(), touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, startX: null, startY: null, direction: null, repeatTimer: null, repeatMs: null}, dpad: {pointerId: null, direction: null, repeatTimer: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
state.moveFlow = new LatestIntentFlow((direction, element) => sendMoveNow(direction, element));
function showNotice(text, kind, priority, persistent, key) {
@@ -332,8 +374,11 @@
function clearEffects() {
windowObject.clearTimeout(state.effects.impactTimer);
windowObject.clearTimeout(state.effects.rateTimer);
windowObject.clearTimeout(state.effects.breakthroughTimer);
state.effects.impactTimer = null;
state.effects.rateTimer = null;
state.effects.breakthroughTimer = null;
state.hedge.openedKeys.clear();
byId("maze").classList.remove("impact-north", "impact-east", "impact-south", "impact-west", "rate-limited");
byId("wall-bump").classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west");
documentObject.querySelectorAll(".cell.bush-impact").forEach(cell => cell.classList.remove("bush-impact", "bush-stage-1", "bush-stage-2", "bush-stage-3", "bush-stage-4"));
@@ -353,7 +398,11 @@
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}`);
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}`); }, 340);
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() {
@@ -470,6 +519,51 @@
return wrapper;
}
function hedgeSymbol(edge) {
return edge.opened ? `hedge-breach-${hedgeVariant(edge, 3)}` : `hedge-intact-${hedgeVariant(edge, 6)}`;
}
function renderHedgeEdges(maze, cells, width, minX, minY) {
const edges = projectedHedgeEdges(cells, width);
const openedKeys = new Set(edges.filter(edge => edge.opened).map(edge => edge.key));
const newOpenedKeys = new Set([...openedKeys].filter(key => !state.hedge.openedKeys.has(key)));
const layer = documentObject.createElement("span");
layer.className = "hedge-layer";
edges.forEach(edge => {
const wrapper = documentObject.createElement("span");
const symbol = hedgeSymbol(edge);
wrapper.className = `hedge-edge ${edge.vertical ? "hedge-vertical" : "hedge-horizontal"}`;
wrapper.dataset.hedgeKey = edge.key;
wrapper.dataset.hedgeSymbol = symbol;
wrapper.style.setProperty("--hedge-x", String(edge.x - minX));
wrapper.style.setProperty("--hedge-y", String(edge.y - minY));
const svg = documentObject.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 160 40");
svg.setAttribute("preserveAspectRatio", "none");
const use = documentObject.createElementNS("http://www.w3.org/2000/svg", "use");
use.setAttribute("href", `/hedges.svg#${symbol}`);
svg.appendChild(use);
wrapper.appendChild(svg);
layer.appendChild(wrapper);
});
maze.appendChild(layer);
state.hedge.openedKeys = openedKeys;
if (newOpenedKeys.size && !state.reducedMotion) {
newOpenedKeys.forEach(key => {
const edge = layer.querySelector(`[data-hedge-key="${key}"]`);
const use = edge && edge.querySelector("use");
if (use) use.setAttribute("href", "/hedges.svg#hedge-breakthrough");
});
windowObject.clearTimeout(state.effects.breakthroughTimer);
state.effects.breakthroughTimer = windowObject.setTimeout(() => {
layer.querySelectorAll(".hedge-edge").forEach(edge => {
const use = edge.querySelector("use");
if (use) use.setAttribute("href", `/hedges.svg#${edge.dataset.hedgeSymbol}`);
});
}, 340);
}
}
function addObject(cell, object) {
const [kind, owner, variant] = object;
let marker;
@@ -527,19 +621,12 @@
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");
if (data.opened & 1) cell.classList.add("trampled-n");
if (data.opened & 2) cell.classList.add("trampled-e");
if (data.opened & 4) cell.classList.add("trampled-s");
if (data.opened & 8) cell.classList.add("trampled-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, slot === 1 ? 2 : 1, true);
maze.appendChild(cell);
});
renderHedgeEdges(maze, snapshot.cells, snapshot.width, minX, minY);
byId("map-placeholder").hidden = true;
}
@@ -601,6 +688,7 @@
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();
@@ -612,14 +700,6 @@
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");
if (opened & 1) cell.classList.add("trampled-n");
if (opened & 2) cell.classList.add("trampled-e");
if (opened & 4) cell.classList.add("trampled-s");
if (opened & 8) cell.classList.add("trampled-w");
(objects.get(index) || []).forEach(object => addObject(cell, object));
players.forEach((player, playerIndex) => {
if (player.cell === index && !player.exited) {
@@ -629,6 +709,7 @@
});
maze.appendChild(cell);
});
renderHedgeEdges(maze, snapshot.cells, snapshot.width, 0, 0);
players.forEach((player, index) => {
const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`;
byId(`observer-p${index + 1}`).textContent = !player.connected ? "нет связи" : player.exited ? "снаружи" : key;
@@ -935,7 +1016,7 @@
return {state, handleMessage, move, resetAvatarControl, latencySamples: () => state.latency.snapshot(), pointerFeedbackSamples: () => state.latency.pointerSnapshot()};
}
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_ADJECTIVES, NAME_NOUNS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, DIRECTIONAL_PAD_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, playerFocalPoint, playerFocalMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot});
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_ADJECTIVES, NAME_NOUNS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, DIRECTIONAL_PAD_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, playerFocalPoint, playerFocalMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, hedgeEdgeKey, hedgeKeyForMove, hedgeVariant, projectedHedgeEdges, clampObserverZoom, observerFitScale, boot});
if (typeof module !== "undefined" && module.exports) module.exports = api;
root.Maze32Player = api;
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });