feat(player): animate authoritative hedge breakthroughs

This commit is contained in:
2026-09-12 23:38:01 +03:00
parent 720cd18b6e
commit 8a37412416
9 changed files with 303 additions and 15 deletions
+5 -1
View File
@@ -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
+188 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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"/);
}
});
+16
View File
@@ -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
View File
@@ -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

+12
View File
@@ -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
View File
@@ -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 ? "Включить движение" : "Уменьшить движение");