feat(web): render canonical living hedge maze walls
This commit is contained in:
@@ -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
|
The browser may correlate a matching server-approved impact stage with an
|
||||||
already visible bush wall; it never derives or opens topology locally.
|
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
|
Player projections are translated only within their received cells so the
|
||||||
authoritative `self` cell aligns with the responsive local focal point. That
|
authoritative `self` cell aligns with the responsive local focal point. That
|
||||||
point is centered horizontally and 30% down the usable portrait map region;
|
point is centered horizontally and 30% down the usable portrait map region;
|
||||||
|
|||||||
@@ -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`.
|
breach variants, and the density and palette defined in `docs/UI_STYLE.md`.
|
||||||
This satisfies the required owner review. Runtime integration remains a
|
This satisfies the required owner review. Runtime integration remains a
|
||||||
separately planned milestone.
|
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`.
|
||||||
|
|||||||
@@ -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",
|
static const maze32_static_asset_t PLAYER_SCRIPT_ASSET = {"/littlefs/player.js.gz",
|
||||||
"text/javascript; charset=utf-8"};
|
"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 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) {
|
static void preserve_static_context(void* 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();
|
httpd_config_t configuration = HTTPD_DEFAULT_CONFIG();
|
||||||
configuration.max_uri_handlers = 13;
|
configuration.max_uri_handlers = 14;
|
||||||
configuration.max_open_sockets = 9;
|
configuration.max_open_sockets = 9;
|
||||||
configuration.lru_purge_enable = false;
|
configuration.lru_purge_enable = false;
|
||||||
configuration.recv_wait_timeout = 5;
|
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,
|
.handler = send_static_asset,
|
||||||
.user_ctx = (void*)&ICON_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 = {
|
const httpd_uri_t health_uri = {
|
||||||
.uri = "/health",
|
.uri = "/health",
|
||||||
.method = HTTP_GET,
|
.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) {
|
if (result == ESP_OK) {
|
||||||
result = httpd_register_uri_handler(*server, &icons);
|
result = httpd_register_uri_handler(*server, &icons);
|
||||||
}
|
}
|
||||||
|
if (result == ESP_OK) {
|
||||||
|
result = httpd_register_uri_handler(*server, &hedges);
|
||||||
|
}
|
||||||
if (result == ESP_OK) {
|
if (result == ESP_OK) {
|
||||||
result = httpd_register_uri_handler(*server, &health_uri);
|
result = httpd_register_uri_handler(*server, &health_uri);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
a permanent trampled bush gap. Effects have one restartable timer and clear on
|
||||||
pause, result, reset, disconnect, and control reset. Reduced motion is static.
|
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
|
All remaining Russian system messages use one `role=status` live message rail
|
||||||
above the application top bar. One connection-loss message has highest priority
|
above the application top bar. One connection-loss message has highest priority
|
||||||
and remains until a successful recovery; other messages deduplicate, replace
|
and remains until a successful recovery; other messages deduplicate, replace
|
||||||
|
|||||||
@@ -158,6 +158,35 @@ are not MVP requirements.
|
|||||||
at 34, 40, and 46 CSS pixels. They contain no coordinates, objects,
|
at 34, 40, and 46 CSS pixels. They contain no coordinates, objects,
|
||||||
ownership, hidden topology, animation, or runtime dependencies.
|
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
|
## 8. Responsive behavior
|
||||||
|
|
||||||
- The minimum supported player viewport is `412 x 916` CSS pixels.
|
- The minimum supported player viewport is `412 x 916` CSS pixels.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const zlib = require("node:zlib");
|
|||||||
|
|
||||||
const samplePath = path.join(__dirname, "..", "..", "docs", "hedge-sample-library.svg");
|
const samplePath = path.join(__dirname, "..", "..", "docs", "hedge-sample-library.svg");
|
||||||
const sample = fs.readFileSync(samplePath, "utf8");
|
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", () => {
|
test("hedge review sheet is local editable SVG with complete stable symbols", () => {
|
||||||
assert.match(sample, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
|
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((sample.match(/<use href=/g) || []).length >= 20);
|
||||||
assert.ok(zlib.gzipSync(sample, {level: 9}).length < sample.length);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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, /moveDirections: new Map\(\)/);
|
||||||
assert.match(script, /if \(message\.payload\.code === "blocked"\) \{ showImpact\(direction, message\.payload\.breakthroughHits\); feedback\(false\); return; \}/);
|
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\.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.match(script, /if \(message\.payload\.code === "rate_limited"\) \{ showRateLimit\(\); feedback\(false\); return; \}/);
|
||||||
assert.doesNotMatch(script, /Там стена\. Очень убедительная\.|Слишком быстро! Сервер просит немного меньше турбо\./);
|
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);
|
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", () => {
|
test("refresh contract restores only the token and waits for server projection", () => {
|
||||||
const restore = player.createEnvelope("restore", 1, "restore-1", {token: "0123456789abcdef0123456789abcdef"});
|
const restore = player.createEnvelope("restore", 1, "restore-1", {token: "0123456789abcdef0123456789abcdef"});
|
||||||
assert.deepEqual(Object.keys(restore.payload), ["token"]);
|
assert.deepEqual(Object.keys(restore.payload), ["token"]);
|
||||||
|
|||||||
@@ -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
@@ -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 { 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; }
|
.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))); }
|
.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 { position: relative; width: var(--cell); height: var(--cell); background: #11131a; }
|
||||||
.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::after { content: ""; position: absolute; inset: 7px; background: #1c1b27; opacity: .55; }
|
.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; }
|
.hedge-layer { position: absolute; z-index: 1; inset: 0; pointer-events: none; overflow: visible; }
|
||||||
.cell.trampled-n::before { left: 4px; right: 4px; top: -3px; height: 6px; }
|
.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; }
|
||||||
.cell.trampled-e::before { top: 4px; right: -3px; bottom: 4px; width: 6px; }
|
.hedge-edge svg { display: block; width: 100%; height: 100%; overflow: visible; }
|
||||||
.cell.trampled-s::before { right: 4px; bottom: -3px; left: 4px; height: 6px; }
|
.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.trampled-w::before { top: 4px; bottom: 4px; left: -3px; width: 6px; }
|
|
||||||
.cell.bush-impact { z-index: 5; animation: bush-resist .34s steps(3, end); }
|
.cell.bush-impact { z-index: 5; animation: bush-resist .34s steps(3, end); }
|
||||||
.cell.bush-stage-2 { filter: saturate(1.25); }
|
.cell.bush-stage-2 { filter: saturate(1.25); }
|
||||||
.cell.bush-stage-3 { filter: brightness(1.2) saturate(1.4); }
|
.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 { 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-stage.dragging { cursor: grabbing; }
|
||||||
.observer-maze { position: absolute; left: 50%; top: 50%; display: grid; transform-origin: center; will-change: transform; }
|
.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 .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 { inset: 5%; }
|
||||||
.observer-maze .object-key::after { display: none; }
|
.observer-maze .object-key::after { display: none; }
|
||||||
.observer-maze .object-landmark { font-size: 6px; }
|
.observer-maze .object-landmark { font-size: 6px; }
|
||||||
|
|||||||
+100
-19
@@ -302,6 +302,48 @@
|
|||||||
return snapshot && Array.isArray(snapshot.cells) ? snapshot.cells.map(cell => cell[0]) : [];
|
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) {
|
function clampObserverZoom(value) {
|
||||||
return Math.min(6, Math.max(.25, value));
|
return Math.min(6, Math.max(.25, value));
|
||||||
}
|
}
|
||||||
@@ -313,7 +355,7 @@
|
|||||||
|
|
||||||
function boot(documentObject, windowObject) {
|
function boot(documentObject, windowObject) {
|
||||||
const byId = id => documentObject.getElementById(id);
|
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));
|
state.moveFlow = new LatestIntentFlow((direction, element) => sendMoveNow(direction, element));
|
||||||
|
|
||||||
function showNotice(text, kind, priority, persistent, key) {
|
function showNotice(text, kind, priority, persistent, key) {
|
||||||
@@ -332,8 +374,11 @@
|
|||||||
function clearEffects() {
|
function clearEffects() {
|
||||||
windowObject.clearTimeout(state.effects.impactTimer);
|
windowObject.clearTimeout(state.effects.impactTimer);
|
||||||
windowObject.clearTimeout(state.effects.rateTimer);
|
windowObject.clearTimeout(state.effects.rateTimer);
|
||||||
|
windowObject.clearTimeout(state.effects.breakthroughTimer);
|
||||||
state.effects.impactTimer = null;
|
state.effects.impactTimer = null;
|
||||||
state.effects.rateTimer = 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("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");
|
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"));
|
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 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}"]`);
|
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}`);
|
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() {
|
function showRateLimit() {
|
||||||
@@ -470,6 +519,51 @@
|
|||||||
return wrapper;
|
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) {
|
function addObject(cell, object) {
|
||||||
const [kind, owner, variant] = object;
|
const [kind, owner, variant] = object;
|
||||||
let marker;
|
let marker;
|
||||||
@@ -527,19 +621,12 @@
|
|||||||
cell.className = "cell";
|
cell.className = "cell";
|
||||||
cell.dataset.cellIndex = String(data.index);
|
cell.dataset.cellIndex = String(data.index);
|
||||||
cell.style.gridColumn = String(data.x - minX + 1); cell.style.gridRow = String(data.y - minY + 1);
|
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));
|
(objects.get(data.index) || []).forEach(object => addObject(cell, object));
|
||||||
const slot = state.session ? state.session.slot : 1;
|
const slot = state.session ? state.session.slot : 1;
|
||||||
if (data.index === snapshot.opponent) addPawn(cell, slot === 1 ? 2 : 1, true);
|
if (data.index === snapshot.opponent) addPawn(cell, slot === 1 ? 2 : 1, true);
|
||||||
maze.appendChild(cell);
|
maze.appendChild(cell);
|
||||||
});
|
});
|
||||||
|
renderHedgeEdges(maze, snapshot.cells, snapshot.width, minX, minY);
|
||||||
byId("map-placeholder").hidden = true;
|
byId("map-placeholder").hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,6 +688,7 @@
|
|||||||
setScreen("observer");
|
setScreen("observer");
|
||||||
const maze = byId("observer-maze");
|
const maze = byId("observer-maze");
|
||||||
maze.replaceChildren();
|
maze.replaceChildren();
|
||||||
|
maze.style.setProperty("--cell", `${OBSERVER_CELL_SIZE}px`);
|
||||||
maze.style.gridTemplateColumns = `repeat(${snapshot.width}, ${OBSERVER_CELL_SIZE}px)`;
|
maze.style.gridTemplateColumns = `repeat(${snapshot.width}, ${OBSERVER_CELL_SIZE}px)`;
|
||||||
maze.style.gridTemplateRows = `repeat(${snapshot.height}, ${OBSERVER_CELL_SIZE}px)`;
|
maze.style.gridTemplateRows = `repeat(${snapshot.height}, ${OBSERVER_CELL_SIZE}px)`;
|
||||||
const objects = new Map();
|
const objects = new Map();
|
||||||
@@ -612,14 +700,6 @@
|
|||||||
cell.className = "cell";
|
cell.className = "cell";
|
||||||
cell.style.gridColumn = String(index % snapshot.width + 1);
|
cell.style.gridColumn = String(index % snapshot.width + 1);
|
||||||
cell.style.gridRow = String(Math.floor(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));
|
(objects.get(index) || []).forEach(object => addObject(cell, object));
|
||||||
players.forEach((player, playerIndex) => {
|
players.forEach((player, playerIndex) => {
|
||||||
if (player.cell === index && !player.exited) {
|
if (player.cell === index && !player.exited) {
|
||||||
@@ -629,6 +709,7 @@
|
|||||||
});
|
});
|
||||||
maze.appendChild(cell);
|
maze.appendChild(cell);
|
||||||
});
|
});
|
||||||
|
renderHedgeEdges(maze, snapshot.cells, snapshot.width, 0, 0);
|
||||||
players.forEach((player, index) => {
|
players.forEach((player, index) => {
|
||||||
const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`;
|
const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`;
|
||||||
byId(`observer-p${index + 1}`).textContent = !player.connected ? "нет связи" : player.exited ? "снаружи" : key;
|
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()};
|
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;
|
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||||
root.Maze32Player = api;
|
root.Maze32Player = api;
|
||||||
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });
|
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });
|
||||||
|
|||||||
Reference in New Issue
Block a user