feat(player): animate authoritative hedge breakthroughs
This commit is contained in:
+5
-1
@@ -345,7 +345,11 @@ already visible bush wall; it never derives or opens topology locally.
|
||||
Browser hedge rendering derives a bounded canonical edge list solely from the
|
||||
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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+7
-3
@@ -59,9 +59,13 @@ a maze boundary, and is recalculated on resize and rotation.
|
||||
The browser stores the direction for each outstanding move request. A matching
|
||||
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
|
||||
|
||||
+14
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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(`<g id="${identifier}">([\\s\\S]*?)</g>`))[1];
|
||||
assert.match(symbol, /fill="#11131a" d="M32 0h96v40H32Z"/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -316,6 +316,22 @@ test("hedge edges are canonical, bounded, and deterministic", () => {
|
||||
assert.equal(player.hedgeKeyForMove(0, 2, "east"), "v:1:0");
|
||||
});
|
||||
|
||||
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"]);
|
||||
|
||||
+3
-3
@@ -15,8 +15,8 @@
|
||||
<g id="hedge-impact-3"><use href="#hedge-intact-1" transform="skewX(-4)"/><path fill="none" stroke="#765033" stroke-width="3" d="m71 28 11-18m1 16 11-13"/></g>
|
||||
<g id="hedge-impact-4"><use href="#hedge-intact-1" transform="translate(0 4) scale(1 .86)"/><path fill="#c7e68a" d="m61 2 8 3-3 6-8-3Zm13 7 8 3-3 6-8-3Zm14-4 8 3-3 6-8-3Z"/></g>
|
||||
<g id="hedge-breakthrough"><use href="#hedge-intact-1"/><path fill="#11131a" stroke="#173d2a" stroke-width="2" d="M62 0h36v40H62Z"/><path fill="#765033" d="m60 6 10 8-4 5-11-8Zm40 12 10-8 5 5-10 9Z"/></g>
|
||||
<g id="hedge-breach-1"><use href="#hedge-breakthrough"/><path fill="#4f9b55" d="M44 28c8-7 14-5 18 0v8H42Zm56 0c7-6 13-5 18 0v8H98Z"/></g>
|
||||
<g id="hedge-breach-2"><use href="#hedge-breakthrough"/><path fill="#296443" d="M35 25c11-8 18-4 27 3v10H33Zm65 3c10-7 18-11 27-3v13H98Z"/></g>
|
||||
<g id="hedge-breach-3"><use href="#hedge-breakthrough"/><path fill="none" stroke="#765033" stroke-width="3" d="m38 33 17-12m50 12 17-12"/></g>
|
||||
<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-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-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>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -151,6 +151,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.maze.rate-limited { filter: brightness(.48) blur(2px); transition: filter .12s linear; }
|
||||
[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); } }
|
||||
|
||||
+51
-1
@@ -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 ? "Включить движение" : "Уменьшить движение");
|
||||
|
||||
Reference in New Issue
Block a user