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