diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6645e84..f99b2f4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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; diff --git a/PLANS.md b/PLANS.md index b7f9732..8256c50 100644 --- a/PLANS.md +++ b/PLANS.md @@ -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`. diff --git a/components/maze32_web/maze32_web.c b/components/maze32_web/maze32_web.c index 392cd80..5d6adec 100644 --- a/components/maze32_web/maze32_web.c +++ b/components/maze32_web/maze32_web.c @@ -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); } diff --git a/docs/PLAYER_UI.md b/docs/PLAYER_UI.md index 9bada48..54d3da4 100644 --- a/docs/PLAYER_UI.md +++ b/docs/PLAYER_UI.md @@ -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 diff --git a/docs/UI_STYLE.md b/docs/UI_STYLE.md index 5957578..da4c32a 100644 --- a/docs/UI_STYLE.md +++ b/docs/UI_STYLE.md @@ -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. diff --git a/test/web/test_hedge_svg.js b/test/web/test_hedge_svg.js index 196f67c..d226e8f 100644 --- a/test/web/test_hedge_svg.js +++ b/test/web/test_hedge_svg.js @@ -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, /^= 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, /^ { + 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"]); diff --git a/web/hedges.svg b/web/hedges.svg new file mode 100644 index 0000000..ca216c4 --- /dev/null +++ b/web/hedges.svg @@ -0,0 +1,22 @@ + diff --git a/web/player.css b/web/player.css index 97f4978..3df9e56 100644 --- a/web/player.css +++ b/web/player.css @@ -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; } diff --git a/web/player.js b/web/player.js index 1654395..1081d1d 100644 --- a/web/player.js +++ b/web/player.js @@ -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); });