feat(player): animate authoritative hedge breakthroughs
This commit is contained in:
+5
-1
@@ -345,7 +345,11 @@ already visible bush wall; it never derives or opens topology locally.
|
|||||||
Browser hedge rendering derives a bounded canonical edge list solely from the
|
Browser hedge rendering derives a bounded canonical edge list solely from the
|
||||||
current role projection. Its deterministic local SVG selection is presentation
|
current role projection. Its deterministic local SVG selection is presentation
|
||||||
only: it cannot create maze edges, alter hit testing, retain hidden cells, or
|
only: it cannot create maze edges, alter hit testing, retain hidden cells, or
|
||||||
change authoritative movement and visibility.
|
change authoritative movement and visibility. A player-only breakthrough
|
||||||
|
animation may begin only when a newer projection contains both a newly opened
|
||||||
|
visible edge and an adjacent server-approved self-cell move across that exact
|
||||||
|
edge; it is bounded, replaceable by a newer projection, and never delays the
|
||||||
|
received state.
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -2905,7 +2905,7 @@ unchanged.
|
|||||||
|
|
||||||
## Milestone 026 — Optional help overlay at match start
|
## Milestone 026 — Optional help overlay at match start
|
||||||
|
|
||||||
**Status:** `IN_PROGRESS`
|
**Status:** `COMPLETE`
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|
||||||
@@ -2939,4 +2939,190 @@ without automatically opening the optional player help overlay.
|
|||||||
|
|
||||||
### Evidence
|
### Evidence
|
||||||
|
|
||||||
- Implementation and verification are pending.
|
- On 2026-09-12, `renderSnapshot()` was changed to dismiss the countdown
|
||||||
|
modal after an active player projection without invoking `showOnboarding()`.
|
||||||
|
The existing `onboarding-replay` button and the skip and final-step close
|
||||||
|
paths remain unchanged.
|
||||||
|
- On 2026-09-12, `node --test test/web/test_player_app.js
|
||||||
|
test/web/test_hedge_svg.js` passed all 26 tests. The new countdown-completion
|
||||||
|
test verifies direct match-screen activation and enabled controls, no
|
||||||
|
automatic help opening, and the preserved optional open and close paths.
|
||||||
|
- On 2026-09-12, `bash scripts/run_host_tests.sh` passed, and `python3
|
||||||
|
scripts/verify_web_assets.py` passed with a deterministic 29,984 /
|
||||||
|
1,572,864 byte compressed web payload.
|
||||||
|
- On 2026-09-12, `/home/sasa/.platformio/penv/bin/pio run --target buildfs`
|
||||||
|
succeeded with the LittleFS image using 2,031,616 / 2,031,616 bytes.
|
||||||
|
- In-app browser verification is pending because the current in-app browser
|
||||||
|
connection is unavailable.
|
||||||
|
- On 2026-09-12, the project owner checked the optional help overlay and
|
||||||
|
confirmed that it is not shown automatically when a game starts. This
|
||||||
|
supplies the missing visual acceptance evidence for Milestone 026.
|
||||||
|
|
||||||
|
## Milestone 027 — Playful breakthrough movement and prominent hedge gaps
|
||||||
|
|
||||||
|
**Status:** `IN_PROGRESS`
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Make a successful perseverance breakthrough feel exciting and immediately
|
||||||
|
understandable to children by synchronizing the player's movement through the
|
||||||
|
wall with a short playful vegetation effect, and by enlarging and sharpening
|
||||||
|
the permanent opening so it is as easy to recognize as an ordinary maze
|
||||||
|
passage on supported mobile and desktop screens.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- Do not start implementation until every preceding milestone is `COMPLETE`.
|
||||||
|
Milestone 026 is still `IN_PROGRESS` when this plan is created, so this
|
||||||
|
milestone must remain `NOT_STARTED` until its visual acceptance is recorded.
|
||||||
|
- Preserve the Milestone 022 authoritative rule: the ESP opens the wall and
|
||||||
|
moves the player on the fifth qualifying impact. The browser may animate
|
||||||
|
only a received server-approved breakthrough and must never predict an
|
||||||
|
opening, delay the authoritative position, or change collision behavior.
|
||||||
|
- Reconcile the breakthrough presentation in `docs/UI_STYLE.md`,
|
||||||
|
`docs/PLAYER_UI.md`, and `ARCHITECTURE.md` before changing runtime assets.
|
||||||
|
- Replace the understated fifth-impact transition with one short,
|
||||||
|
child-friendly sequence: the hedge compresses away from the impact point,
|
||||||
|
the player performs a readable squash-and-stretch hop or energetic dash
|
||||||
|
through the opening, and a small bounded burst of leaves and soft sparkle
|
||||||
|
shapes follows the motion. Keep the effect cheerful rather than violent,
|
||||||
|
alarming, or realistic.
|
||||||
|
- Synchronize the effect with the authoritative origin and destination cells.
|
||||||
|
The player must begin at the old visible position, cross the exact opened
|
||||||
|
edge, and settle at the new server-approved position without teleport-like
|
||||||
|
flicker, duplicate avatars, or a frame spent on top of a closed hedge.
|
||||||
|
- Keep the complete breakthrough sequence brief enough not to slow repeated
|
||||||
|
play, target approximately 350--550 ms in normal-motion mode, and do not
|
||||||
|
block directional input longer than required to reconcile the authoritative
|
||||||
|
snapshot. Later snapshots must safely supersede an unfinished animation.
|
||||||
|
- Increase the clear center of every permanent breached-hedge variant. At the
|
||||||
|
minimum supported cell size, the visible gap must occupy at least 60% of the
|
||||||
|
traversable edge span, with the remaining foliage pulled into two distinct
|
||||||
|
side clusters rather than forming a faint interruption in an otherwise
|
||||||
|
continuous wall.
|
||||||
|
- Give breached edges an unmistakable top-down silhouette using bent branches,
|
||||||
|
displaced leaves, and a subtle trampled-ground or scattered-leaf cue on both
|
||||||
|
sides of the opening. Do not add a bright line, rectangular floor grid,
|
||||||
|
arrow, warning text, or color-only marker.
|
||||||
|
- Ensure the enlarged decoration reads as open space from either direction and
|
||||||
|
in horizontal and vertical orientations. It must not resemble a thin closed
|
||||||
|
wall, cover the passage center, overlap the player, key, artifact, exit, or
|
||||||
|
door, or make adjacent closed edges ambiguous.
|
||||||
|
- Keep authoritative maze geometry, pathfinding, visibility, hit testing,
|
||||||
|
object interactions, and protocol contents unchanged unless a documented
|
||||||
|
contract gap proves that an existing breakthrough event cannot identify the
|
||||||
|
required visible edge and movement transition.
|
||||||
|
- Reuse the local SVG hedge family where practical. Any new SVG shapes must be
|
||||||
|
editable, script-free, raster-free, locally packaged, and consistent with
|
||||||
|
the approved top-down leaves, twigs, outlines, and palette.
|
||||||
|
- Bound all temporary leaves, sparkles, DOM nodes, timers, and animation state.
|
||||||
|
Coalesce or replace repeated effects and clear them on projection loss,
|
||||||
|
screen change, pause, disconnect, result, reset, and reduced-motion changes.
|
||||||
|
- In reduced-motion mode, immediately place the player at the authoritative
|
||||||
|
destination and switch to the enlarged permanent gap with a brief static
|
||||||
|
emphasis only. Do not require translation, shake, squash, flying leaves,
|
||||||
|
sparkle motion, sound, vibration, or color to communicate success.
|
||||||
|
- Apply the improved permanent gap to player and observer maps. The observer
|
||||||
|
does not need player travel animation but must see the same clear opened-edge
|
||||||
|
silhouette at overview and inspection zoom levels.
|
||||||
|
- Preserve supported mobile layouts, directional controls, player focal-point
|
||||||
|
positioning, Russian player-facing copy, offline operation, and firmware and
|
||||||
|
LittleFS budgets.
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
- Updated breakthrough motion, permanent-gap, layering, timing, cleanup, and
|
||||||
|
reduced-motion contracts in canonical and supporting documentation.
|
||||||
|
- Revised horizontal and vertical permanent-breach SVG variants with a larger
|
||||||
|
clear center and distinct displaced-foliage silhouettes.
|
||||||
|
- A bounded, server-triggered player breakthrough animation with synchronized
|
||||||
|
hedge compression, player movement, and playful leaf and sparkle accents.
|
||||||
|
- Player and observer renderer updates that use the more prominent permanent
|
||||||
|
breach without changing authoritative maze geometry.
|
||||||
|
- Deterministic browser tests, web-asset validation, host regression results,
|
||||||
|
size evidence, and representative mobile, desktop, observer, reduced-motion,
|
||||||
|
and physical-device visual verification.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
1. A fifth qualifying impact still causes exactly one server-authoritative wall
|
||||||
|
opening and movement. The browser starts the effect only from the matching
|
||||||
|
authoritative breakthrough response and never predicts, repeats, or delays
|
||||||
|
gameplay state.
|
||||||
|
2. In normal-motion mode, the player visibly travels from the authoritative
|
||||||
|
origin cell through the exact breached edge to the authoritative destination
|
||||||
|
in one coherent 350--550 ms sequence, without teleport flicker, duplicate
|
||||||
|
avatars, stale positions, or overlap with an apparently closed wall.
|
||||||
|
3. The sequence includes a cheerful and visually coherent hedge compression or
|
||||||
|
parting action, player squash-and-stretch hop or dash, and a bounded leaf and
|
||||||
|
sparkle accent. Its meaning remains clear with sound and vibration disabled.
|
||||||
|
4. At 34, 40, and 46 CSS-pixel cell sizes, the clear center of each permanent
|
||||||
|
breached variant spans at least 60% of the traversable edge and is visibly
|
||||||
|
wider than the pre-milestone breach. Automated geometry checks cover every
|
||||||
|
horizontal and vertical variant.
|
||||||
|
5. Every permanent breach reads as an open passage at a glance from both sides
|
||||||
|
and in all orientations. Its two separated foliage clusters, bent branches,
|
||||||
|
and restrained ground debris remain distinguishable from an intact wall in
|
||||||
|
normal color, grayscale, and representative color-vision checks.
|
||||||
|
6. The enlarged gap does not obscure or overlap the player, keys, artifacts,
|
||||||
|
doors, exits, controls, neighboring passage centers, or closed-edge
|
||||||
|
silhouettes, and it introduces no map-shell or page overflow.
|
||||||
|
7. Unrelated successful movement retains its existing behavior. The special
|
||||||
|
travel effect is used only for a confirmed fifth-impact breakthrough and
|
||||||
|
cannot replay from duplicate, delayed, reordered, or stale events.
|
||||||
|
8. A newer authoritative snapshot safely completes, replaces, or cancels an
|
||||||
|
unfinished visual transition while leaving exactly one player at the latest
|
||||||
|
approved cell and the correct current projected topology.
|
||||||
|
9. The implementation documents and tests fixed upper bounds for temporary
|
||||||
|
nodes, particles, timers, and queued effects. Pause, disconnect, result,
|
||||||
|
reset, screen change, and projection loss leave no stale transforms,
|
||||||
|
decorations, classes, or animation state.
|
||||||
|
10. Reduced-motion mode performs no travel, shake, squash, flying-leaf, or
|
||||||
|
sparkle animation. It immediately shows the authoritative destination and
|
||||||
|
the same prominent permanent gap with a non-motion, non-color-only cue.
|
||||||
|
11. Player and observer maps render the same deterministic breached-edge
|
||||||
|
family. Observer pan and zoom remain responsive, and openings stay legible
|
||||||
|
at overview and inspection scales without changing observer read-only
|
||||||
|
behavior.
|
||||||
|
12. Browser tests cover authoritative triggering, timing, origin-to-destination
|
||||||
|
alignment, all breach variants and orientations, the minimum 60% gap,
|
||||||
|
deduplication, interrupted transitions, cleanup, reduced motion, supported
|
||||||
|
mobile and desktop viewports, observer rendering, layering, and overflow.
|
||||||
|
13. All host and browser tests pass; SVG and compressed-web-asset validation
|
||||||
|
passes; the payload remains within the 1.5 MiB web budget and LittleFS
|
||||||
|
partition; and firmware remains within the factory partition.
|
||||||
|
14. Physical-device testing confirms stable two-player play during repeated
|
||||||
|
and simultaneous breakthroughs, with no input lockup, stale avatar, lost
|
||||||
|
projection, accumulated effects, or visible performance regression.
|
||||||
|
15. The project owner confirms on a representative mobile browser that the
|
||||||
|
breakthrough effect is fun and understandable for children, the movement
|
||||||
|
feels connected to breaking the bushes, and the resulting passage is
|
||||||
|
immediately obvious and at least as readable as an ordinary open route, or
|
||||||
|
explicitly approves a documented evidence deviation.
|
||||||
|
|
||||||
|
### Evidence
|
||||||
|
|
||||||
|
- On 2026-09-12, the project owner reported that the current permanent breach
|
||||||
|
is too small and visually weak, and requested a larger, sharper gap plus a
|
||||||
|
child-friendly special effect for player movement through a broken hedge.
|
||||||
|
- Planning-time inspection of the active mobile game at
|
||||||
|
`http://192.168.1.50/` confirmed that the small interruption and remaining
|
||||||
|
near-continuous foliage silhouette make the breach difficult to distinguish
|
||||||
|
from an intact hedge at gameplay scale.
|
||||||
|
- On 2026-09-12, implementation began after Milestone 026 was completed. The
|
||||||
|
player renderer now recognizes only a newly opened current-projection edge
|
||||||
|
paired with an adjacent authoritative self move, uses a single replaceable
|
||||||
|
480 ms runner with four decorative accents, and clears it on newer views,
|
||||||
|
control reset, screen change, and reduced motion. Permanent breach symbols
|
||||||
|
now reserve a 96 / 160 (60%) clear centre.
|
||||||
|
- On 2026-09-12, `node --test test/web/test_player_app.js
|
||||||
|
test/web/test_hedge_svg.js` passed all 30 tests, including authoritative
|
||||||
|
opening detection, fixed 480 ms travel, four-accent bounds, reduced-motion
|
||||||
|
cleanup, and all three 60%-centre breach variants.
|
||||||
|
- On 2026-09-12, `bash scripts/run_host_tests.sh` passed all portable and
|
||||||
|
bundled browser tests. `python3 scripts/verify_web_assets.py` passed with a
|
||||||
|
deterministic 31,071 / 1,572,864 byte compressed web payload, and
|
||||||
|
`/home/sasa/.platformio/penv/bin/pio run --target buildfs` succeeded with a
|
||||||
|
2,031,616 / 2,031,616 byte LittleFS image.
|
||||||
|
- Mobile, desktop, observer, reduced-motion, and physical-device visual
|
||||||
|
acceptance remain required before this milestone can be marked `COMPLETE`.
|
||||||
|
|||||||
+7
-3
@@ -59,9 +59,13 @@ a maze boundary, and is recalculated on resize and rotation.
|
|||||||
The browser stores the direction for each outstanding move request. A matching
|
The browser stores the direction for each outstanding move request. A matching
|
||||||
server `blocked` rejection may include only that player's approved breakthrough
|
server `blocked` rejection may include only that player's approved breakthrough
|
||||||
stage, which bends the already rendered bush wall for at most 340 ms without
|
stage, which bends the already rendered bush wall for at most 340 ms without
|
||||||
blocked-move text. Opened-passage bits in an ordinary cell projection render as
|
blocked-move text. A received opened-passage projection paired with an adjacent
|
||||||
a permanent trampled bush gap. Effects have one restartable timer and clear on
|
authoritative player move may show one 480 ms hedge-parting dash and four
|
||||||
pause, result, reset, disconnect, and control reset. Reduced motion is static.
|
bounded decorative accents before settling at the server-approved destination.
|
||||||
|
Opened-passage bits render a permanent trampled bush gap with a 60% clear
|
||||||
|
centre. Effects have restartable timers and clear on a newer projection, pause,
|
||||||
|
result, reset, disconnect, screen change, and control reset. Reduced motion is
|
||||||
|
static and settles directly at the received destination.
|
||||||
|
|
||||||
The map draws only a bounded canonical hedge-edge layer generated from current
|
The map draws only a bounded canonical hedge-edge layer generated from current
|
||||||
received cells. It hashes each visible edge deterministically to choose the
|
received cells. It hashes each visible edge deterministically to choose the
|
||||||
|
|||||||
+14
-5
@@ -130,9 +130,13 @@ are not MVP requirements.
|
|||||||
- Server-approved breakthrough impacts one through four bend the visible bush
|
- Server-approved breakthrough impacts one through four bend the visible bush
|
||||||
wall with four increasing restrained stages and no text or spike notice. A
|
wall with four increasing restrained stages and no text or spike notice. A
|
||||||
fifth accepted move is reflected by a permanent textured trampled-bush gap.
|
fifth accepted move is reflected by a permanent textured trampled-bush gap.
|
||||||
The browser uses one restartable impact timer and no generated particles.
|
Only a received opened-edge projection paired with an adjacent authoritative
|
||||||
|
player position may start one 480 ms hedge-parting, player dash, and fixed
|
||||||
|
four-accent leaf-and-sparkle sequence. It uses one restartable timer and no
|
||||||
|
unbounded generated particles.
|
||||||
`rate_limited` still darkens and blurs only the maze for at most 520 ms.
|
`rate_limited` still darkens and blurs only the maze for at most 520 ms.
|
||||||
- Reduced-motion mode uses static deformation and the same trampled-gap state.
|
- Reduced-motion mode uses static deformation and the same trampled-gap state;
|
||||||
|
it never shows travel or moving accents.
|
||||||
|
|
||||||
### Hedge art review contract
|
### Hedge art review contract
|
||||||
|
|
||||||
@@ -152,8 +156,9 @@ are not MVP requirements.
|
|||||||
- Impact stages one through four retain the same wall centre while increasingly
|
- Impact stages one through four retain the same wall centre while increasingly
|
||||||
compressing foliage, bending twigs, and shedding a fixed small number of
|
compressing foliage, bending twigs, and shedding a fixed small number of
|
||||||
leaves. The breakthrough and trampled symbols leave a clear centred passage
|
leaves. The breakthrough and trampled symbols leave a clear centred passage
|
||||||
at least 16 CSS pixels wide at 40-pixel scale, with flattened vegetation on
|
at least 24 CSS pixels wide at 40-pixel scale, with two separated foliage
|
||||||
both sides. They never imply movement beyond that one authoritative edge.
|
clusters, bent branches, and restrained ground debris on both sides. They
|
||||||
|
never imply movement beyond that one authoritative edge.
|
||||||
- Review sheets show the source symbols on neutral and dark-cell backgrounds
|
- Review sheets show the source symbols on neutral and dark-cell backgrounds
|
||||||
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.
|
||||||
@@ -185,7 +190,11 @@ are not MVP requirements.
|
|||||||
objects and players, and may overhang an edge by at most 15% of a cell. A
|
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
|
projection replacement removes all obsolete edge nodes. One restartable
|
||||||
breakthrough timer may briefly show the approved breakthrough frame; reduced
|
breakthrough timer may briefly show the approved breakthrough frame; reduced
|
||||||
motion settles immediately on the permanent breach.
|
motion settles immediately on the permanent breach. A player-only transient
|
||||||
|
runner replaces the focal avatar for at most 480 ms after a matching opened
|
||||||
|
edge and adjacent authoritative move; it has exactly four non-interactive
|
||||||
|
accents and is cleared on any newer projection, pause, disconnect, result,
|
||||||
|
reset, screen change, or reduced-motion mode.
|
||||||
|
|
||||||
## 8. Responsive behavior
|
## 8. Responsive behavior
|
||||||
|
|
||||||
|
|||||||
@@ -43,3 +43,10 @@ test("runtime hedge sprite remains local and contains approved runtime symbols",
|
|||||||
}
|
}
|
||||||
assert.ok(zlib.gzipSync(runtime, {level: 9}).length < 4096);
|
assert.ok(zlib.gzipSync(runtime, {level: 9}).length < 4096);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("permanent hedge breaches leave a 60 percent clear centre", () => {
|
||||||
|
for (const identifier of ["hedge-breach-1", "hedge-breach-2", "hedge-breach-3"]) {
|
||||||
|
const symbol = runtime.match(new RegExp(`<g id="${identifier}">([\\s\\S]*?)</g>`))[1];
|
||||||
|
assert.match(symbol, /fill="#11131a" d="M32 0h96v40H32Z"/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -316,6 +316,22 @@ test("hedge edges are canonical, bounded, and deterministic", () => {
|
|||||||
assert.equal(player.hedgeKeyForMove(0, 2, "east"), "v:1:0");
|
assert.equal(player.hedgeKeyForMove(0, 2, "east"), "v:1:0");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("breakthrough travel is bounded and starts only from an opened authoritative edge", () => {
|
||||||
|
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
||||||
|
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||||
|
|
||||||
|
assert.match(script, /function breakthroughDirection\(previous, snapshot\)/);
|
||||||
|
assert.match(script, /projectedHedgeEdges\(snapshot\.cells, snapshot\.width\)\.some\(edge => edge\.key === key && edge\.opened\)/);
|
||||||
|
assert.match(script, /if \(direction\) showBreakthroughTravel\(direction\);\n else stopBreakthroughTravel\(\);/);
|
||||||
|
assert.match(script, /for \(let index = 0; index < 4; index\+\+\)/);
|
||||||
|
assert.match(script, /setTimeout\(stopBreakthroughTravel, 480\)/);
|
||||||
|
assert.match(script, /if \(reduced\) stopBreakthroughTravel\(\);/);
|
||||||
|
assert.match(script, /if \(name !== "match"\) stopBreakthroughTravel\(\);/);
|
||||||
|
assert.match(css, /\.breakthrough-runner \{[^}]*pointer-events: none[^}]*480ms/);
|
||||||
|
assert.match(css, /@keyframes breakthrough-run/);
|
||||||
|
assert.match(css, /\[data-reduced-motion="true"\] \.breakthrough-runner/);
|
||||||
|
});
|
||||||
|
|
||||||
test("refresh contract restores only the token and waits for server projection", () => {
|
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"]);
|
||||||
|
|||||||
+3
-3
@@ -15,8 +15,8 @@
|
|||||||
<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-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-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-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-1"><use href="#hedge-intact-1"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#296443" d="M0 8c12-9 23-6 32 2v22c-11 6-22 4-32-3Zm128 2c11-8 22-11 32-2v21c-10 7-21 9-32 3Z"/><path fill="none" stroke="#765033" stroke-width="3" d="m8 30 17-15m127 15-17-15"/><path fill="#4f9b55" d="m27 30 6-5 5 7-7 4Zm99 2 7-7 6 5-5 6Z"/></g>
|
||||||
<g id="hedge-breach-2"><use href="#hedge-breakthrough"/><path fill="#296443" d="M35 25c11-8 18-4 27 3v10H33Zm65 3c10-7 18-11 27-3v13H98Z"/></g>
|
<g id="hedge-breach-2"><use href="#hedge-intact-3"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#173d2a" d="M0 5c14-7 25-5 32 5v22c-10 5-22 4-32-2Zm128 5c8-10 19-12 32-5v25c-11 6-22 7-32 2Z"/><path fill="none" stroke="#765033" stroke-width="4" d="M4 32 21 9m7 25L15 19m141 13-17-23m-7 25 13-15"/><path fill="#8cc968" d="m25 28 7-6 5 7-8 5Zm98 1 7-7 6 6-7 5Z"/></g>
|
||||||
<g id="hedge-breach-3"><use href="#hedge-breakthrough"/><path fill="none" stroke="#765033" stroke-width="3" d="m38 33 17-12m50 12 17-12"/></g>
|
<g id="hedge-breach-3"><use href="#hedge-intact-5"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#4f9b55" d="M0 10c10-10 22-8 32 0v21c-12 6-23 3-32-4Zm128 0c10-8 22-10 32 0v17c-10 9-21 10-32 4Z"/><path fill="none" stroke="#765033" stroke-width="3" d="M5 31 23 12m-9 22 14-13m132-10-18-19m9 22-14-13"/><path fill="#296443" d="m29 30 7-5 4 7-7 3Zm94 3 6-8 7 5-5 7Z"/></g>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -151,6 +151,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
|||||||
.maze.rate-limited { filter: brightness(.48) blur(2px); transition: filter .12s linear; }
|
.maze.rate-limited { filter: brightness(.48) blur(2px); transition: filter .12s linear; }
|
||||||
[data-reduced-motion="true"] .maze[class*="impact-"] { animation: none; }
|
[data-reduced-motion="true"] .maze[class*="impact-"] { animation: none; }
|
||||||
[data-reduced-motion="true"] .maze.rate-limited { transition: none; }
|
[data-reduced-motion="true"] .maze.rate-limited { transition: none; }
|
||||||
|
[data-reduced-motion="true"] .breakthrough-runner, [data-reduced-motion="true"] .breakthrough-accent { display: none; }
|
||||||
[data-reduced-motion="true"] .wall-bump.show { border-width: 4px; }
|
[data-reduced-motion="true"] .wall-bump.show { border-width: 4px; }
|
||||||
[data-reduced-motion="true"] .wall-bump.impact-north { border-top-color: var(--error); }
|
[data-reduced-motion="true"] .wall-bump.impact-north { border-top-color: var(--error); }
|
||||||
[data-reduced-motion="true"] .wall-bump.impact-east { border-right-color: var(--error); }
|
[data-reduced-motion="true"] .wall-bump.impact-east { border-right-color: var(--error); }
|
||||||
@@ -162,6 +163,15 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
|||||||
.avatar-handle { position: absolute; left: 50%; top: 50%; width: 52px; height: 52px; display: grid; place-items: center; border: 3px solid currentColor; border-radius: 50%; background: transparent; color: var(--p1); box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead236; font-size: 1.25rem; font-weight: 1000; transform: translate(-50%, -50%); transition: transform .12s steps(3, end); }
|
.avatar-handle { position: absolute; left: 50%; top: 50%; width: 52px; height: 52px; display: grid; place-items: center; border: 3px solid currentColor; border-radius: 50%; background: transparent; color: var(--p1); box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead236; font-size: 1.25rem; font-weight: 1000; transform: translate(-50%, -50%); transition: transform .12s steps(3, end); }
|
||||||
.player-two-mode .avatar-handle { color: var(--p2); }
|
.player-two-mode .avatar-handle { color: var(--p2); }
|
||||||
.avatar-control.active .avatar-handle { transition: none; }
|
.avatar-control.active .avatar-handle { transition: none; }
|
||||||
|
.avatar-control.breakthrough-travelling { opacity: 0; }
|
||||||
|
.breakthrough-runner { position: absolute; z-index: 9; left: var(--player-focal-x, 50%); top: var(--player-focal-y, 50%); width: 52px; height: 52px; display: grid; place-items: center; color: var(--p1); pointer-events: none; animation: breakthrough-run 480ms steps(6, end) forwards; }
|
||||||
|
.breakthrough-runner.player-two { color: var(--p2); }
|
||||||
|
.breakthrough-runner svg { width: 100%; height: 100%; padding: 5%; border: 3px solid currentColor; background: #0b0712; box-shadow: 3px 3px 0 #000; }
|
||||||
|
.breakthrough-accent { position: absolute; width: 8px; height: 8px; border: 2px solid #c7e68a; background: var(--plant); box-shadow: 1px 1px 0 #173d2a; animation: breakthrough-accent 480ms steps(5, end) forwards; }
|
||||||
|
.accent-1 { left: 4px; top: 6px; }
|
||||||
|
.accent-2 { right: 2px; top: 12px; background: var(--aqua); }
|
||||||
|
.accent-3 { left: 12px; bottom: 1px; background: #8cc968; }
|
||||||
|
.accent-4 { right: 9px; bottom: 5px; background: var(--warning); }
|
||||||
.avatar-player-icon { width: 74%; height: 74%; }
|
.avatar-player-icon { width: 74%; height: 74%; }
|
||||||
.avatar-direction-icon { position: absolute; width: 19px; height: 19px; right: -13px; top: -13px; display: none; padding: 2px; border: 1px solid currentColor; background: #08050b; color: var(--aqua); }
|
.avatar-direction-icon { position: absolute; width: 19px; height: 19px; right: -13px; top: -13px; display: none; padding: 2px; border: 1px solid currentColor; background: #08050b; color: var(--aqua); }
|
||||||
.avatar-control[data-direction] .avatar-direction-icon { display: block; }
|
.avatar-control[data-direction] .avatar-direction-icon { display: block; }
|
||||||
@@ -322,3 +332,5 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
|||||||
@keyframes modal-in { from { opacity: 0; transform: scale(.82); } }
|
@keyframes modal-in { from { opacity: 0; transform: scale(.82); } }
|
||||||
@keyframes count { 50% { transform: scale(1.08); } }
|
@keyframes count { 50% { transform: scale(1.08); } }
|
||||||
@keyframes guide-hand { 50% { transform: translate(18px, -12px); } }
|
@keyframes guide-hand { 50% { transform: translate(18px, -12px); } }
|
||||||
|
@keyframes breakthrough-run { 0% { transform: translate(calc(-50% + var(--breakthrough-start-x)), calc(-50% + var(--breakthrough-start-y))) scale(.78, 1.16); } 42% { transform: translate(calc(-50% + var(--breakthrough-mid-x)), calc(-50% + var(--breakthrough-mid-y))) scale(1.16, .78); } 100% { transform: translate(-50%, -50%) scale(1); } }
|
||||||
|
@keyframes breakthrough-accent { 0% { opacity: 0; transform: translate(0, 0) scale(.4); } 25% { opacity: 1; } 100% { opacity: 0; transform: translate(var(--breakthrough-start-x, 12px), -18px) scale(1.35); } }
|
||||||
|
|||||||
+51
-1
@@ -355,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, 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}};
|
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, travelTimer: 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) {
|
||||||
@@ -375,10 +375,14 @@
|
|||||||
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);
|
windowObject.clearTimeout(state.effects.breakthroughTimer);
|
||||||
|
windowObject.clearTimeout(state.effects.travelTimer);
|
||||||
state.effects.impactTimer = null;
|
state.effects.impactTimer = null;
|
||||||
state.effects.rateTimer = null;
|
state.effects.rateTimer = null;
|
||||||
state.effects.breakthroughTimer = null;
|
state.effects.breakthroughTimer = null;
|
||||||
|
state.effects.travelTimer = null;
|
||||||
state.hedge.openedKeys.clear();
|
state.hedge.openedKeys.clear();
|
||||||
|
documentObject.querySelectorAll(".breakthrough-runner").forEach(runner => runner.remove());
|
||||||
|
byId("avatar-control").classList.remove("breakthrough-travelling");
|
||||||
byId("maze").classList.remove("impact-north", "impact-east", "impact-south", "impact-west", "rate-limited");
|
byId("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"));
|
||||||
@@ -418,6 +422,7 @@
|
|||||||
byId("match-screen").hidden = name !== "match";
|
byId("match-screen").hidden = name !== "match";
|
||||||
byId("observer-screen").hidden = name !== "observer";
|
byId("observer-screen").hidden = name !== "observer";
|
||||||
documentObject.body.classList.toggle("match-active", name === "match");
|
documentObject.body.classList.toggle("match-active", name === "match");
|
||||||
|
if (name !== "match") stopBreakthroughTravel();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWelcomeHeadline(headline) {
|
function renderWelcomeHeadline(headline) {
|
||||||
@@ -524,6 +529,45 @@
|
|||||||
return edge.opened ? `hedge-breach-${hedgeVariant(edge, 3)}` : `hedge-intact-${hedgeVariant(edge, 6)}`;
|
return edge.opened ? `hedge-breach-${hedgeVariant(edge, 3)}` : `hedge-intact-${hedgeVariant(edge, 6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function breakthroughDirection(previous, snapshot) {
|
||||||
|
if (!previous || previous.role === "observer" || snapshot.role === "observer" || previous.width !== snapshot.width) return null;
|
||||||
|
const delta = snapshot.self - previous.self;
|
||||||
|
const directions = {[-snapshot.width]: "north", [1]: "east", [snapshot.width]: "south", [-1]: "west"};
|
||||||
|
const direction = directions[delta];
|
||||||
|
if (!direction || (direction === "east" && previous.self % snapshot.width === snapshot.width - 1) || (direction === "west" && previous.self % snapshot.width === 0)) return null;
|
||||||
|
const key = hedgeKeyForMove(previous.self, snapshot.width, direction);
|
||||||
|
return projectedHedgeEdges(snapshot.cells, snapshot.width).some(edge => edge.key === key && edge.opened) ? direction : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopBreakthroughTravel() {
|
||||||
|
windowObject.clearTimeout(state.effects.travelTimer);
|
||||||
|
state.effects.travelTimer = null;
|
||||||
|
documentObject.querySelectorAll(".breakthrough-runner").forEach(runner => runner.remove());
|
||||||
|
byId("avatar-control").classList.remove("breakthrough-travelling");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBreakthroughTravel(direction) {
|
||||||
|
stopBreakthroughTravel();
|
||||||
|
if (!direction || state.reducedMotion) return;
|
||||||
|
const mapShell = byId("map-shell");
|
||||||
|
const cellSize = Number.parseFloat(byId("maze").style.getPropertyValue("--cell"));
|
||||||
|
if (!Number.isFinite(cellSize)) return;
|
||||||
|
const offsets = {north: {x: 0, y: cellSize}, east: {x: -cellSize, y: 0}, south: {x: 0, y: -cellSize}, west: {x: cellSize, y: 0}};
|
||||||
|
const runner = icon("player", `breakthrough-runner player-${state.session && state.session.slot === 2 ? "two" : "one"}`);
|
||||||
|
runner.style.setProperty("--breakthrough-start-x", `${offsets[direction].x}px`);
|
||||||
|
runner.style.setProperty("--breakthrough-start-y", `${offsets[direction].y}px`);
|
||||||
|
runner.style.setProperty("--breakthrough-mid-x", `${offsets[direction].x * .42}px`);
|
||||||
|
runner.style.setProperty("--breakthrough-mid-y", `${offsets[direction].y * .42}px`);
|
||||||
|
for (let index = 0; index < 4; index++) {
|
||||||
|
const accent = documentObject.createElement("span");
|
||||||
|
accent.className = `breakthrough-accent accent-${index + 1}`;
|
||||||
|
runner.appendChild(accent);
|
||||||
|
}
|
||||||
|
mapShell.appendChild(runner);
|
||||||
|
byId("avatar-control").classList.add("breakthrough-travelling");
|
||||||
|
state.effects.travelTimer = windowObject.setTimeout(stopBreakthroughTravel, 480);
|
||||||
|
}
|
||||||
|
|
||||||
function renderHedgeEdges(maze, cells, width, minX, minY) {
|
function renderHedgeEdges(maze, cells, width, minX, minY) {
|
||||||
const edges = projectedHedgeEdges(cells, width);
|
const edges = projectedHedgeEdges(cells, width);
|
||||||
const openedKeys = new Set(edges.filter(edge => edge.opened).map(edge => edge.key));
|
const openedKeys = new Set(edges.filter(edge => edge.opened).map(edge => edge.key));
|
||||||
@@ -636,6 +680,8 @@
|
|||||||
renderObserverSnapshot(snapshot);
|
renderObserverSnapshot(snapshot);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const previous = state.snapshot;
|
||||||
|
const direction = breakthroughDirection(previous, snapshot);
|
||||||
state.snapshot = snapshot;
|
state.snapshot = snapshot;
|
||||||
setScreen("match");
|
setScreen("match");
|
||||||
const slot = state.session && state.session.slot ? state.session.slot : 1;
|
const slot = state.session && state.session.slot ? state.session.slot : 1;
|
||||||
@@ -657,6 +703,8 @@
|
|||||||
byId("compass-stat").dataset.direction = hint;
|
byId("compass-stat").dataset.direction = hint;
|
||||||
byId("compass-arrow").style.transform = `rotate(${HINT_ANGLES[hint]}deg)`;
|
byId("compass-arrow").style.transform = `rotate(${HINT_ANGLES[hint]}deg)`;
|
||||||
renderMap(snapshot);
|
renderMap(snapshot);
|
||||||
|
if (direction) showBreakthroughTravel(direction);
|
||||||
|
else stopBreakthroughTravel();
|
||||||
if (Number.isInteger(snapshot.stateSequence)) {
|
if (Number.isInteger(snapshot.stateSequence)) {
|
||||||
windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence));
|
windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence));
|
||||||
}
|
}
|
||||||
@@ -849,6 +897,7 @@
|
|||||||
function resetPlayerControls() {
|
function resetPlayerControls() {
|
||||||
resetAvatarControl();
|
resetAvatarControl();
|
||||||
resetDirectionalPad();
|
resetDirectionalPad();
|
||||||
|
stopBreakthroughTravel();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetAvatarControl() {
|
function resetAvatarControl() {
|
||||||
@@ -911,6 +960,7 @@
|
|||||||
|
|
||||||
function setReducedMotion(reduced) {
|
function setReducedMotion(reduced) {
|
||||||
state.reducedMotion = reduced;
|
state.reducedMotion = reduced;
|
||||||
|
if (reduced) stopBreakthroughTravel();
|
||||||
documentObject.documentElement.dataset.reducedMotion = String(reduced);
|
documentObject.documentElement.dataset.reducedMotion = String(reduced);
|
||||||
byId("motion-toggle").setAttribute("aria-pressed", String(reduced));
|
byId("motion-toggle").setAttribute("aria-pressed", String(reduced));
|
||||||
byId("motion-toggle").setAttribute("aria-label", reduced ? "Включить движение" : "Уменьшить движение");
|
byId("motion-toggle").setAttribute("aria-label", reduced ? "Включить движение" : "Уменьшить движение");
|
||||||
|
|||||||
Reference in New Issue
Block a user