diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index f99b2f4..c107ad8 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -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
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.
+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
authoritative `self` cell aligns with the responsive local focal point. That
diff --git a/PLANS.md b/PLANS.md
index 93cbac0..0324f95 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -2905,7 +2905,7 @@ unchanged.
## Milestone 026 — Optional help overlay at match start
-**Status:** `IN_PROGRESS`
+**Status:** `COMPLETE`
### Objective
@@ -2939,4 +2939,190 @@ without automatically opening the optional player help overlay.
### 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`.
diff --git a/docs/PLAYER_UI.md b/docs/PLAYER_UI.md
index 54d3da4..cdf0fef 100644
--- a/docs/PLAYER_UI.md
+++ b/docs/PLAYER_UI.md
@@ -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
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
-blocked-move text. Opened-passage bits in an ordinary cell projection render as
-a permanent trampled bush gap. Effects have one restartable timer and clear on
-pause, result, reset, disconnect, and control reset. Reduced motion is static.
+blocked-move text. A received opened-passage projection paired with an adjacent
+authoritative player move may show one 480 ms hedge-parting dash and four
+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
received cells. It hashes each visible edge deterministically to choose the
diff --git a/docs/UI_STYLE.md b/docs/UI_STYLE.md
index da4c32a..b495e86 100644
--- a/docs/UI_STYLE.md
+++ b/docs/UI_STYLE.md
@@ -130,9 +130,13 @@ are not MVP requirements.
- Server-approved breakthrough impacts one through four bend the visible bush
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.
- 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.
-- 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
@@ -152,8 +156,9 @@ are not MVP requirements.
- Impact stages one through four retain the same wall centre while increasingly
compressing foliage, bending twigs, and shedding a fixed small number of
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
- both sides. They never imply movement beyond that one authoritative edge.
+ at least 24 CSS pixels wide at 40-pixel scale, with two separated foliage
+ 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
at 34, 40, and 46 CSS pixels. They contain no coordinates, objects,
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
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.
+ 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
diff --git a/test/web/test_hedge_svg.js b/test/web/test_hedge_svg.js
index d226e8f..0a07d81 100644
--- a/test/web/test_hedge_svg.js
+++ b/test/web/test_hedge_svg.js
@@ -43,3 +43,10 @@ test("runtime hedge sprite remains local and contains approved runtime symbols",
}
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(`([\\s\\S]*?)`))[1];
+ assert.match(symbol, /fill="#11131a" d="M32 0h96v40H32Z"/);
+ }
+});
diff --git a/test/web/test_player_app.js b/test/web/test_player_app.js
index f5e0ecc..1ce9f4c 100644
--- a/test/web/test_player_app.js
+++ b/test/web/test_player_app.js
@@ -316,6 +316,22 @@ test("hedge edges are canonical, bounded, and deterministic", () => {
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", () => {
const restore = player.createEnvelope("restore", 1, "restore-1", {token: "0123456789abcdef0123456789abcdef"});
assert.deepEqual(Object.keys(restore.payload), ["token"]);
diff --git a/web/hedges.svg b/web/hedges.svg
index ca216c4..77f10fe 100644
--- a/web/hedges.svg
+++ b/web/hedges.svg
@@ -15,8 +15,8 @@
-
-
-
+
+
+
diff --git a/web/player.css b/web/player.css
index c87487b..272cbe1 100644
--- a/web/player.css
+++ b/web/player.css
@@ -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; }
[data-reduced-motion="true"] .maze[class*="impact-"] { animation: 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.impact-north { border-top-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); }
.player-two-mode .avatar-handle { color: var(--p2); }
.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-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; }
@@ -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 count { 50% { transform: scale(1.08); } }
@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); } }
diff --git a/web/player.js b/web/player.js
index 377db20..a9ddb62 100644
--- a/web/player.js
+++ b/web/player.js
@@ -355,7 +355,7 @@
function boot(documentObject, windowObject) {
const byId = id => documentObject.getElementById(id);
- const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null, 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));
function showNotice(text, kind, priority, persistent, key) {
@@ -375,10 +375,14 @@
windowObject.clearTimeout(state.effects.impactTimer);
windowObject.clearTimeout(state.effects.rateTimer);
windowObject.clearTimeout(state.effects.breakthroughTimer);
+ windowObject.clearTimeout(state.effects.travelTimer);
state.effects.impactTimer = null;
state.effects.rateTimer = null;
state.effects.breakthroughTimer = null;
+ state.effects.travelTimer = null;
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("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"));
@@ -418,6 +422,7 @@
byId("match-screen").hidden = name !== "match";
byId("observer-screen").hidden = name !== "observer";
documentObject.body.classList.toggle("match-active", name === "match");
+ if (name !== "match") stopBreakthroughTravel();
}
function renderWelcomeHeadline(headline) {
@@ -524,6 +529,45 @@
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) {
const edges = projectedHedgeEdges(cells, width);
const openedKeys = new Set(edges.filter(edge => edge.opened).map(edge => edge.key));
@@ -636,6 +680,8 @@
renderObserverSnapshot(snapshot);
return;
}
+ const previous = state.snapshot;
+ const direction = breakthroughDirection(previous, snapshot);
state.snapshot = snapshot;
setScreen("match");
const slot = state.session && state.session.slot ? state.session.slot : 1;
@@ -657,6 +703,8 @@
byId("compass-stat").dataset.direction = hint;
byId("compass-arrow").style.transform = `rotate(${HINT_ANGLES[hint]}deg)`;
renderMap(snapshot);
+ if (direction) showBreakthroughTravel(direction);
+ else stopBreakthroughTravel();
if (Number.isInteger(snapshot.stateSequence)) {
windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence));
}
@@ -849,6 +897,7 @@
function resetPlayerControls() {
resetAvatarControl();
resetDirectionalPad();
+ stopBreakthroughTravel();
}
function resetAvatarControl() {
@@ -911,6 +960,7 @@
function setReducedMotion(reduced) {
state.reducedMotion = reduced;
+ if (reduced) stopBreakthroughTravel();
documentObject.documentElement.dataset.reducedMotion = String(reduced);
byId("motion-toggle").setAttribute("aria-pressed", String(reduced));
byId("motion-toggle").setAttribute("aria-label", reduced ? "Включить движение" : "Уменьшить движение");