feat(ui): replace gameplay icons with local SVGs

This commit is contained in:
2026-09-10 21:47:15 +03:00
parent 8214e7f7ed
commit abb3ff27b7
11 changed files with 162 additions and 74 deletions
+6
View File
@@ -323,6 +323,12 @@ renders only server-provided visible state. The observer view supports fitting,
panning, or zooming the complete `32 x 32` map without changing server
authority.
The browser renders Player 1 as yellow and Player 2 as blue. The matching key
and exit use the owner's color as a secondary identity signal, while distinct
SVG silhouettes and open/closed treatments identify object type and state. This
presentation mapping is local only and does not alter authoritative game or
projection data.
`docs/UI_STYLE.md` is the canonical browser presentation contract. The player
layout supports a minimum `412 x 916` CSS-pixel viewport. Observer mode requires
at least `1024 x 768` CSS pixels and presents a large-screen requirement message
+5 -3
View File
@@ -222,9 +222,11 @@ Browser controls must support:
`docs/UI_STYLE.md`.
- All player-facing UI copy, game messages, and browser errors are in Russian.
- The UI uses a near-black background, purple panels, turquoise interactive
accents, blue for Player 1, pink for Player 2, and plant-like green maze
walls. Important state is distinguished by icons, labels, outlines, or
shapes in addition to color.
accents, yellow for Player 1, blue for Player 2, and plant-like green maze
walls. Each player's matching key and exit use the same ownership color.
Important state is distinguished by icons, labels, outlines, or shapes in
addition to color; amber warnings and blue pause states use distinct
treatments so they cannot be confused with player identity.
- The player map consumes all space not required by fixed status and control
panels. The player view supports a minimum `412 x 916` CSS-pixel viewport
without page scrolling.
+38 -10
View File
@@ -1690,7 +1690,7 @@ map moves below it; displacement controls speed; approximately 100 ms is ideal,
## Milestone 018 — Mobile directional controls and viewport refinement
**Status:** `IN_PROGRESS`
**Status:** `COMPLETE`
### Objective
@@ -1819,13 +1819,10 @@ planning-time observation motivates the milestone:
run -e esp32-c6-devkitm-1` passed with 58,868 of 327,680 RAM bytes and
1,027,966 of 2,097,152 application bytes. The matching `--target buildfs`
command passed and produced the fixed 2,031,616-byte LittleFS image.
- A representative physical mobile-browser touch/layout confirmation remains
required for acceptance criterion 11. Milestone 018 remains `IN_PROGRESS`;
Milestone 019 has not been started.
## Milestone 019 — Reference-derived SVG icon system
**Status:** `NOT_STARTED`
**Status:** `IN_PROGRESS`
### Objective
@@ -1929,14 +1926,45 @@ matching keys, and matching exits.
### Evidence
Completion evidence is not yet available because SVG authoring and application
replacement have not started. The following planning-time observation motivates
the milestone but does not satisfy its acceptance criteria:
Implementation and automated verification are in progress. The following
evidence satisfies the source, integration, and automated acceptance criteria;
the project-owner visual confirmation remains outstanding:
- On 2026-09-10, the project owner supplied a raster visual reference containing
a yellow running figure, yellow key, red closed arched door, cyan faceted
crystal, green opened arched door with an outward arrow, and red spikes. The
owner requested that local SVG artwork be created from these examples before
icon replacement, and selected yellow and blue as the two player colors.
Implementation and completion verification are deferred until Milestone 018
is `COMPLETE`.
- On 2026-09-10, `web/icons.svg` was replaced with project-owned editable SVG
symbols `player`, `key`, `exit-closed`, `crystal`, `exit-open`, and `spikes`.
Each reference-derived symbol uses the documented `64 x 64` view box and
`currentColor`; the sprite contains no `<image>`, `foreignObject`, or embedded
raster data. `xmllint --noout web/icons.svg` passed.
- The player, key, closed/opened exit, vision-artifact, blocked-movement,
onboarding, avatar, lobby, observer, and result renderers now use the new
SVG symbols where the supplied reference applies. Player 1 is yellow
(`#ffd33d`) and Player 2 is blue (`#41b6ff`); their keys and exits share the
matching ownership color, while dashed secondary treatments and distinct
silhouettes preserve non-color identification. Amber warnings and
periwinkle pause semantics remain distinct from player identities. Spikes
are used only in the client-side blocked-movement notice.
- `node --test test/web/test_player_app.js` passed all 21 tests, including the
new deterministic SVG-structure, ownership-color, local-asset, and
blocked-feedback checks. `bash scripts/run_host_tests.sh` passed all host
suites and deterministic asset verification
(`c7f3bd8217e51e81b46eaeaf2636f5cd1cf85a7d8e27b07cb5293ea97a1f3f41`,
26,510 of 1,572,864 allowed compressed bytes).
- `/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1` passed with
58,868 of 327,680 RAM bytes and 1,027,966 of 2,097,152 application bytes.
The matching `--target buildfs` command passed and reported the fixed
2,031,616-byte LittleFS image within its partition.
- On 2026-09-10, `pio run -e esp32-c6-devkitm-1 --target uploadfs
--upload-port /dev/ttyACM0` wrote and hash-verified the LittleFS image to the
confirmed ESP32-C6FH4. A read-only live-page check at `412 x 916` CSS pixels
confirmed a `412 x 916` document with no overflow and references to the
local `player` and `spikes` symbols. It did not create or alter a player
session, so full active-match visual confirmation remains for the project
owner.
- Milestone 019 remains `IN_PROGRESS` pending the project owner's
representative mobile and desktop confirmation that the new icon shapes,
yellow/blue ownership, keys, and exits are clear at normal gameplay size.
Binary file not shown.
+14
View File
@@ -12,6 +12,20 @@ locally authored `icons.svg` sprite. The deterministic asset build compresses
each file with gzip for LittleFS. There are no remote fonts, frameworks,
analytics, icon services, or runtime network dependencies beyond the local ESP.
## Gameplay icon mapping
`icons.svg` contains editable, local SVG symbols with a shared `64 x 64` view
box for the reference-derived gameplay set: `player`, `key`, `exit-closed`,
`crystal`, `exit-open`, and `spikes`. Player 1 is yellow and Player 2 is blue;
their matching keys and exits use the same owner color. The player, key, and
exit silhouettes remain distinct, and Player 2 uses a dashed secondary
treatment so ownership does not depend on color alone.
The closed and opened arched exits show the existing locked and opened exit
states. The crystal represents the existing vision artifact. Spikes appear only
with local blocked-movement feedback and do not add a trap, damage, protocol,
or server-state feature.
## Input contract
Arrow keys and WASD map to `north`, `east`, `south`, and `west` move intentions.
+10 -4
View File
@@ -30,10 +30,11 @@ roles must remain stable:
- black or near-black for the application and map background;
- purple for panels and elevated surfaces;
- turquoise for interactive accents;
- blue for Player 1 and pink for Player 2 across every screen, map object, and
message;
- green for success, yellow for warnings, red for errors, blue for pause, and
orange for disconnection;
- yellow for Player 1 and blue for Player 2 across every screen, map object,
and message; each player's matching key and exit use the same ownership
color;
- green for success, amber for warnings, red for errors, periwinkle for pause,
and orange for disconnection;
- green vegetation, bushes, and plants for maze walls.
When two semantic roles use related colors, icons, labels, outlines, or shapes
@@ -41,6 +42,11 @@ must keep them unambiguous. Important player and object information must not be
communicated by color alone. Key objects use an additional label, outline, or
marker.
The gameplay icon family is locally authored SVG artwork derived from the
approved running-player, key, arched-exit, crystal, opened-exit, and spike
reference silhouettes. The spike icon communicates only blocked movement or a
wall collision; it never represents a trap or a new gameplay mechanic.
WCAG AA contrast certification and dedicated color-vision-deficiency palettes
are not MVP requirements.
+25
View File
@@ -269,6 +269,31 @@ test("player assets are local, Russian, accessible, and responsive", () => {
assert.match(icons, /id="maze"/);
});
test("reference-derived SVG gameplay symbols preserve local yellow and blue ownership", () => {
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const playerUi = fs.readFileSync(path.join(project, "docs/PLAYER_UI.md"), "utf8");
for (const iconId of ["player", "key", "exit-closed", "crystal", "exit-open", "spikes"]) {
assert.match(icons, new RegExp(`<symbol id="${iconId}" viewBox="0 0 64 64">`));
}
assert.doesNotMatch(icons, /<(?:image|foreignObject)\b/i);
assert.doesNotMatch(icons, /data:image\//i);
assert.match(script, /icon\(variant \? "exit-open" : "exit-closed", "object object-exit"\)/);
assert.match(script, /icon\("crystal", "object object-vision"\)/);
assert.match(script, /icon\("player", `pawn/);
assert.match(html, /\/icons\.svg#spikes/);
assert.match(css, /--p1: #ffd33d/);
assert.match(css, /--p2: #41b6ff/);
assert.match(css, /--warning: #ffad38/);
assert.match(css, /--pause: #9b8cff/);
assert.match(css, /\.pawn-owner-two \{ border-style: dashed; \}/);
assert.match(html, /Жёлтый/);
assert.match(html, /Синий/);
assert.match(playerUi, /`player`, `key`, `exit-closed`,\n`crystal`, `exit-open`, and `spikes`/);
});
test("observer view fits large screens and bounds zoom", () => {
assert.equal(player.observerFitScale(1024, 600, 32, 32), 2.25);
assert.equal(player.clampObserverZoom(.01), .25);
+7 -2
View File
@@ -1,5 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="key" viewBox="0 0 24 24"><circle cx="8" cy="9" r="4" fill="none" stroke="currentColor" stroke-width="3"/><path d="M11 12l9 9m-4-4 2-2m-5-1 2-2" fill="none" stroke="currentColor" stroke-width="3"/></symbol>
<!-- Reference-derived gameplay symbols use a shared 64 by 64 view box and currentColor. -->
<symbol id="player" viewBox="0 0 64 64"><path fill="currentColor" d="M37 5a9 9 0 1 1 0 18 9 9 0 0 1 0-18Zm-8 20 10-2 10 7 8-5a6 6 0 1 1 6 10l-12 8a6 6 0 0 1-7 0l-7-5-3 11 12 8a6 6 0 0 1-7 10L24 57a6 6 0 0 1-2-7l4-15-7 4-4 10a6 6 0 1 1-11-4l5-14a6 6 0 0 1 3-3l13-7a6 6 0 0 1 4 4Z"/></symbol>
<symbol id="key" viewBox="0 0 64 64"><path fill="currentColor" fill-rule="evenodd" d="M43 5a17 17 0 0 0-14 27L5 56l3 3 5-2 3 4 5-2 3 4 6-6-4-4 7-7A17 17 0 1 0 43 5Zm0 11a6 6 0 1 1 0 12 6 6 0 0 1 0-12Z" clip-rule="evenodd"/></symbol>
<symbol id="exit-closed" viewBox="0 0 64 64"><path fill="currentColor" d="M15 59V29C15 10 49 10 49 29v30H15Zm10-30v30h14V29c0-8-14-8-14 0Zm-17-8 6 2-2 10-7-2 3-10Zm42 2 6-2 3 10-7 2-2-10ZM7 38h7v11H7V38Zm43 0h7v11h-7V38ZM14 9l7-5 4 9-7 3-4-7Zm32-5 7 5-4 7-7-3 4-9Z"/></symbol>
<symbol id="exit-open" viewBox="0 0 64 64"><path fill="currentColor" d="M15 59V29C15 10 49 10 49 29v30H15Zm10-30v30h14V29c0-8-14-8-14 0ZM7 38h7v11H7V38Zm43 0h7v11h-7V38ZM14 9l7-5 4 9-7 3-4-7Zm32-5 7 5-4 7-7-3 4-9Z"/><path fill="currentColor" d="M25 36h10v-7l14 12-14 12v-7H25V36Z"/></symbol>
<symbol id="crystal" viewBox="0 0 64 64"><path fill="currentColor" fill-rule="evenodd" d="m32 5 19 20-19 34L13 25 32 5Zm0 12-8 9h16l-8-9Zm-11 17 11 17 11-17H21Z" clip-rule="evenodd"/><path fill="currentColor" d="M32 1h4v8h-4V1ZM5 29h9v5H5v-5Zm45 0h9v5h-9v-5ZM10 11l6-6 4 4-6 6-4-4Zm34-2 4-4 6 6-4 4-6-6ZM10 53l4-4 6 6-4 4-6-6Zm34 2 6-6 4 4-6 6-4-4Z"/></symbol>
<symbol id="spikes" viewBox="0 0 64 64"><path fill="currentColor" d="m9 47 9-32 9 32H9Zm18 0 9-32 9 32H27Zm18 0 9-32 9 32H45ZM5 51h54v9H5v-9Z"/></symbol>
<symbol id="eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="currentColor"/></symbol>
<symbol id="arrow" viewBox="0 0 24 24"><path d="M12 2l7 9h-4v11H9V11H5l7-9Z" fill="currentColor"/></symbol>
<symbol id="signal" viewBox="0 0 24 24"><path d="M3 9a14 14 0 0 1 18 0M6 13a9 9 0 0 1 12 0m-9 4a4 4 0 0 1 6 0" fill="none" stroke="currentColor" stroke-width="2.5"/><circle cx="12" cy="21" r="1.5" fill="currentColor"/></symbol>
@@ -9,7 +15,6 @@
<symbol id="motion" viewBox="0 0 24 24"><path d="M4 12h6m4 0h6M8 7l5 5-5 5M16 7l-5 5 5 5" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M9.5 9a2.7 2.7 0 1 1 4.2 2.2c-1.4.9-1.7 1.4-1.7 2.8M12 17h.01" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="hand" viewBox="0 0 24 24"><path d="M8 12V5a1.5 1.5 0 0 1 3 0v5V3.5a1.5 1.5 0 0 1 3 0V10V5a1.5 1.5 0 0 1 3 0v6V8a1.5 1.5 0 0 1 3 0v6c0 4-3 7-7 7-3 0-5-2-6-5l-1-3a1.5 1.5 0 0 1 2-1z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></symbol>
<symbol id="exit" viewBox="0 0 24 24"><path d="M4 3h12v18H4zM16 8h4v13H8" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linejoin="round"/><path d="m11 12 5-4m-5 4 5 4m-5-4H2" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="friends" viewBox="0 0 24 24"><circle cx="8" cy="8" r="3" fill="none" stroke="currentColor" stroke-width="2.5"/><circle cx="17" cy="10" r="3" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M3 21c0-4 2-7 5-7s5 3 5 7m1-1c.5-3 1.7-5 4-5 2 0 3 2 3 5" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="maze" viewBox="0 0 160 112"><path d="M8 8h144v96H8V8Zm20 0v24H8m44-24v20h28v28H52v24H28v24m52-48h28V32h24v48h20M80 80v24m28-48v48" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="square"/><circle cx="20" cy="92" r="7" fill="currentColor"/><path d="m135 89 9 7-9 7" fill="none" stroke="currentColor" stroke-width="6"/></symbol>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

+6 -6
View File
@@ -34,8 +34,8 @@
<div class="lobby-grid">
<article class="panel roster-panel">
<h2>Игроки</h2>
<div class="player-card player-one" id="player-1"><span class="avatar">P1</span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
<div class="player-card player-two" id="player-2"><span class="avatar">P2</span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
<div class="player-card player-one" id="player-1"><span class="avatar" aria-label="Жёлтый игрок"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg></span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
<div class="player-card player-two" id="player-2"><span class="avatar" aria-label="Синий игрок"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg></span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
</article>
<article class="panel observer-panel"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><h2>Зрительный зал</h2><p><strong id="observer-count">0</strong> / 4 эксперта по чужим ошибкам.</p></article>
<article class="panel launch-panel"><h2>Большая красная кнопка</h2><p id="launch-help">Старт доступен Игроку 1, когда оба слота на месте.</p><button class="button primary launch-button" id="start-match" type="button" disabled>Начать 3—2—1</button></article>
@@ -44,7 +44,7 @@
<section class="screen match-screen" id="match-screen" hidden aria-label="Игровой экран">
<div class="player-status" id="player-status">
<div class="identity" id="identity"><span class="avatar character-avatar" id="self-slot" aria-hidden="true">●</span><span><strong id="self-name">Неизвестный герой</strong><small id="match-state">Лабиринт думает</small></span></div>
<div class="identity" id="identity"><span class="avatar character-avatar" id="self-slot" aria-hidden="true"><svg><use href="/icons.svg#player"></use></svg></span><span><strong id="self-name">Неизвестный герой</strong><small id="match-state">Лабиринт думает</small></span></div>
<div class="stat" id="inventory-stat"><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><span><small>Ключ</small><strong id="inventory">Пусто</strong></span></div>
<div class="stat" id="vision-stat"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><span><small>Суперзрение</small><strong id="vision">Нет</strong></span></div>
<div class="stat compass-stat" id="compass-stat"><svg id="compass-arrow" aria-hidden="true"><use href="/icons.svg#arrow"></use></svg><span><small>Компас</small><strong id="compass">Молчит</strong></span></div>
@@ -52,8 +52,8 @@
<div class="map-shell" id="map-shell" tabindex="0" aria-label="Лабиринт. Используйте стрелки, WASD или свайпы для движения">
<div class="unknown-message" id="map-placeholder"><svg aria-hidden="true"><use href="/icons.svg#radar"></use></svg><span>Ждём разрешённый сервером кусочек карты…</span></div>
<div class="maze" id="maze" role="img" aria-label="Видимая часть лабиринта"></div>
<button class="avatar-control" id="avatar-control" type="button" aria-label="Тянуть героя для движения"><span class="avatar-handle" id="avatar-handle" aria-hidden="true"><span id="avatar-handle-symbol">●</span><svg><use href="/icons.svg#arrow"></use></svg></span></button>
<div class="wall-bump" id="wall-bump" role="status" aria-live="polite"></div>
<button class="avatar-control" id="avatar-control" type="button" aria-label="Тянуть героя для движения"><span class="avatar-handle" id="avatar-handle" aria-hidden="true"><svg class="avatar-player-icon"><use href="/icons.svg#player"></use></svg><svg class="avatar-direction-icon"><use href="/icons.svg#arrow"></use></svg></span></button>
<div class="wall-bump" id="wall-bump" role="status" aria-live="polite"><svg aria-hidden="true"><use href="/icons.svg#spikes"></use></svg><span class="wall-bump-text"></span></div>
</div>
<div class="child-controls" aria-label="Игровые действия">
<button class="drop-key" id="drop-key" type="button" aria-label="Сбросить ключ" disabled><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><strong>OK</strong><span>бросить</span></button>
@@ -72,7 +72,7 @@
<section class="screen observer-screen" id="observer-screen" hidden aria-labelledby="observer-title">
<div class="observer-toolbar">
<div><p class="eyebrow">Режим наблюдателя · только чтение</p><h1 id="observer-title">Весь куст целиком</h1></div>
<div class="observer-players" aria-live="polite"><span class="player-one">P1 <strong id="observer-p1">готов</strong></span><span class="player-two">P2 <strong id="observer-p2">готов</strong></span><span id="observer-phase">Ждём старта</span></div>
<div class="observer-players" aria-live="polite"><span class="player-one"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg> Жёлтый <strong id="observer-p1">готов</strong></span><span class="player-two"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg> Синий <strong id="observer-p2">готов</strong></span><span id="observer-phase">Ждём старта</span></div>
<div class="zoom-controls" aria-label="Масштаб карты"><button id="zoom-out" type="button" aria-label="Уменьшить масштаб">−</button><button id="zoom-reset" type="button">Вся карта</button><button id="zoom-in" type="button" aria-label="Увеличить масштаб">+</button></div>
</div>
<div class="observer-requirement" id="observer-requirement" role="status"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><strong>Нужен экран побольше</strong><span>Зрительный зал открывается от 1024 × 768 пикселей.</span></div>
+32 -26
View File
@@ -8,14 +8,15 @@
--ink: #f6efff;
--muted: #b7a8ca;
--aqua: #43ead2;
--p1: #58aaff;
--p2: #ff72bd;
--p1: #ffd33d;
--p2: #41b6ff;
--plant: #45b86b;
--plant-dark: #176638;
--success: #72e48c;
--warning: #ffd65a;
--warning: #ffad38;
--vision: #37c9ff;
--error: #ff647a;
--pause: #5598ff;
--pause: #9b8cff;
--disconnect: #ff9f43;
--cell: 40px;
--shadow: #040207;
@@ -84,8 +85,10 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.player-card.player-one { border-left: 5px solid var(--p1); }
.player-card.player-two { border-left: 5px solid var(--p2); }
.avatar { width: 46px; height: 46px; display: grid; place-items: center; border: 2px solid currentColor; background: #0b0712; font-weight: 1000; }
.avatar svg { width: 82%; height: 82%; }
.player-one .avatar, .identity.player-one .avatar { color: var(--p1); }
.player-two .avatar, .identity.player-two .avatar { color: var(--p2); }
.player-two .avatar { border-style: dashed; }
.player-card strong, .player-card small, .identity strong, .identity small, .stat small, .stat strong { display: block; }
.player-card small, .identity small, .stat small { margin-top: 4px; color: var(--muted); font-size: .66rem; }
.status-dot { width: 10px; height: 10px; background: var(--error); }
@@ -116,38 +119,41 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.cell.wall-w { border-left-color: var(--plant); }
.cell::after { content: ""; position: absolute; inset: 7px; background: #1c1b27; opacity: .55; }
.object, .pawn { position: absolute; z-index: 3; inset: 16%; display: grid; place-items: center; }
.object svg { width: 100%; height: 100%; }
.object-key { color: var(--warning); }
.object-key::after { content: "●"; position: absolute; right: -5px; bottom: -6px; padding: 1px 3px; background: #090711; border: 1px solid currentColor; font-size: 8px; font-weight: 900; }
.object-key[data-owner="two"]::after { content: "◆"; }
.object-exit { inset: 8%; border: 3px double currentColor; background: #0b0911; }
.object svg, .pawn svg { width: 100%; height: 100%; }
.object-key { color: var(--p1); }
.object-key[data-owner="two"] { color: var(--p2); }
.object-key::after { content: ""; position: absolute; right: -4px; bottom: -4px; width: 8px; height: 8px; border: 2px solid currentColor; border-radius: 50%; background: #090711; }
.object-key[data-owner="two"]::after { border-radius: 0; transform: rotate(45deg); }
.object-exit { inset: 8%; background: #0b0911; }
.object-exit[data-owner="one"] { color: var(--p1); }
.object-exit[data-owner="two"] { color: var(--p2); transform: rotate(45deg); }
.object-exit[data-owner="two"] { color: var(--p2); }
.object-exit[data-owner="two"]::after { content: ""; position: absolute; inset: 6%; border: 2px dashed currentColor; }
.object-exit[data-open="1"] { background: #72e48c35; animation: exit-open .8s steps(2, end) infinite; }
.object-compass { color: var(--aqua); border: 2px dashed currentColor; border-radius: 50%; }
.object-landmark { color: var(--plant); font-size: 1.15rem; text-shadow: 2px 2px #000; }
.object-vision { color: var(--warning); border-radius: 50%; background: #ffd65a30; box-shadow: 0 0 10px var(--warning); animation: artifact 1.2s steps(4, end) infinite; }
.pawn { inset: 12%; border: 3px solid currentColor; background: #0b0712; font-size: .62rem; font-weight: 1000; box-shadow: 3px 3px 0 #000; animation: pawn-in .16s steps(2, end); }
.object-vision { color: var(--vision); border-radius: 50%; background: #37c9ff30; box-shadow: 0 0 10px var(--vision); animation: artifact 1.2s steps(4, end) infinite; }
.pawn { inset: 12%; padding: 5%; border: 3px solid currentColor; background: #0b0712; box-shadow: 3px 3px 0 #000; animation: pawn-in .16s steps(2, end); }
.pawn-self { color: var(--p1); }
.match-screen.player-two-mode .pawn-self { color: var(--p2); }
.pawn-opponent { color: var(--p2); transform: rotate(45deg); }
.pawn-opponent { color: var(--p2); }
.match-screen.player-two-mode .pawn-opponent { color: var(--p1); }
.pawn-opponent span { transform: rotate(-45deg); }
.wall-bump { position: absolute; left: 50%; bottom: 18px; z-index: 6; min-height: 28px; transform: translateX(-50%); padding: 6px 10px; color: var(--error); background: #260b12dd; opacity: 0; }
.pawn-owner-two { border-style: dashed; }
.wall-bump { position: absolute; left: 50%; bottom: 18px; z-index: 6; display: flex; align-items: center; gap: 6px; min-height: 28px; transform: translateX(-50%); padding: 6px 10px; color: var(--error); background: #260b12dd; opacity: 0; }
.wall-bump.show { animation: bump .8s steps(3, end); }
.wall-bump::before { content: "▌"; display: inline-block; margin-right: 6px; font-size: 1.35em; font-weight: 1000; }
.wall-bump svg { width: 27px; height: 27px; flex: 0 0 27px; }
.avatar-control { position: absolute; z-index: 8; left: 50%; top: 50%; width: 96px; height: 96px; margin: -48px; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: grab; touch-action: none; user-select: none; }
.avatar-control:active, .avatar-control.active { cursor: grabbing; }
.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: #0b0712; 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-handle svg { 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-handle svg { display: block; }
.avatar-control[data-direction="north"] .avatar-handle svg { transform: rotate(0deg); }
.avatar-control[data-direction="east"] .avatar-handle svg { transform: rotate(90deg); }
.avatar-control[data-direction="south"] .avatar-handle svg { transform: rotate(180deg); }
.avatar-control[data-direction="west"] .avatar-handle svg { transform: rotate(270deg); }
.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; }
.avatar-control[data-direction="north"] .avatar-direction-icon { transform: rotate(0deg); }
.avatar-control[data-direction="east"] .avatar-direction-icon { transform: rotate(90deg); }
.avatar-control[data-direction="south"] .avatar-direction-icon { transform: rotate(180deg); }
.avatar-control[data-direction="west"] .avatar-direction-icon { transform: rotate(270deg); }
.avatar-control[data-speed="fast"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 4px var(--aqua), 0 0 18px #43ead299; }
.avatar-control[data-speed="slow"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead270; }
.child-controls { position: absolute; z-index: 12; inset: auto 0 12px; display: flex; align-items: end; justify-content: space-between; padding: 0 16px; pointer-events: none; }
@@ -202,7 +208,8 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.observer-players { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: .72rem; }
.observer-players span { padding: 7px; border: 1px solid #51386e; }
.observer-players .player-one { border-color: var(--p1); }
.observer-players .player-two { border-color: var(--p2); }
.observer-players .player-two { border-color: var(--p2); border-style: dashed; }
.observer-players svg { width: 16px; height: 16px; vertical-align: middle; }
.zoom-controls { display: flex; gap: 5px; }
.zoom-controls button { min-width: 42px; min-height: 38px; border: 1px solid var(--aqua); background: #102a30; color: var(--aqua); cursor: pointer; }
.observer-stage { position: absolute; inset: 92px 0 0; overflow: hidden; border: 2px solid #2f2041; background: #050408; cursor: grab; touch-action: none; user-select: none; }
@@ -213,10 +220,9 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.observer-maze .object { inset: 5%; }
.observer-maze .object-key::after { display: none; }
.observer-maze .object-landmark { font-size: 6px; }
.observer-maze .pawn { inset: -30%; z-index: 5; font-size: 5px; border-width: 1px; box-shadow: 1px 1px 0 #000; }
.observer-maze .pawn { inset: -30%; z-index: 5; padding: 1px; border-width: 1px; box-shadow: 1px 1px 0 #000; }
.observer-maze .pawn-player-one { color: var(--p1); }
.observer-maze .pawn-player-two { color: var(--p2); transform: rotate(45deg); }
.observer-maze .pawn-player-two span { transform: rotate(-45deg); }
.observer-maze .pawn-player-two { color: var(--p2); }
.observer-requirement { display: none; position: absolute; inset: 92px 0 0; place-content: center; justify-items: center; gap: 12px; padding: 24px; color: var(--muted); text-align: center; border: 2px solid var(--edge); background: var(--panel-deep); }
.observer-requirement svg { width: 58px; height: 58px; color: var(--p2); }
.observer-requirement strong { color: var(--ink); font-size: 1.2rem; text-transform: uppercase; }
+19 -23
View File
@@ -19,11 +19,11 @@
const ONBOARDING_STEPS = Object.freeze([
Object.freeze({icon: "hand", title: "Потяни героя", text: "Потяни героя — лабиринт поедет под ним."}),
Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}),
Object.freeze({icon: "maze", title: "Стена не пускает", text: "Упрись в стену — попробуй другую сторону."}),
Object.freeze({icon: "spikes", title: "Стена не пускает", text: "Упрись в стену — попробуй другую сторону."}),
Object.freeze({icon: "key", title: "Найди ключ", text: "Ключ открывает твой выход."}),
Object.freeze({icon: "eye", title: "Глаз расширяет карту", text: "Возьми глаз — увидишь дальше."}),
Object.freeze({icon: "crystal", title: "Кристалл расширяет карту", text: "Возьми кристалл — увидишь дальше."}),
Object.freeze({icon: "arrow", title: "Стрелка знает путь", text: "Компас показывает сторону выхода."}),
Object.freeze({icon: "exit", title: "Выходи с ключом", text: "Коснись своего выхода с ключом."}),
Object.freeze({icon: "exit-closed", title: "Выходи с ключом", text: "Коснись своего выхода с ключом."}),
Object.freeze({icon: "friends", title: "Встретил друга", text: "Другой герой может отдать найденный ключ."})
]);
const NAME_ADJECTIVES = Object.freeze(["Бодрый", "Хитрый", "Тихий", "Смелый", "Ловкий", "Сонный", "Шустрый", "Лунный", "Яркий", "Дикий", "Вечный", "Храбрый"]);
@@ -421,29 +421,28 @@
function addObject(cell, object) {
const [kind, owner, variant] = object;
let marker;
const ownerVariant = owner === 1 ? "two" : "one";
if (kind === "key") {
marker = icon("key", "object object-key"); marker.dataset.owner = owner === 1 ? "two" : "one";
marker = icon("key", "object object-key"); marker.dataset.owner = ownerVariant;
} else if (kind === "exit") {
marker = documentObject.createElement("span"); marker.className = "object object-exit"; marker.dataset.owner = owner === 1 ? "two" : "one"; marker.dataset.open = String(variant);
marker = icon(variant ? "exit-open" : "exit-closed", "object object-exit"); marker.dataset.owner = ownerVariant; marker.dataset.open = String(variant);
} else if (kind === "compass") {
marker = icon("arrow", "object object-compass");
} else if (kind === "vision") {
marker = icon("eye", "object object-vision");
marker = icon("crystal", "object object-vision");
} else {
marker = documentObject.createElement("span"); marker.className = "object object-landmark"; marker.textContent = ["♣", "✿", "♠", "⌂"][variant % 4] || "♣";
}
cell.appendChild(marker);
}
function addPawn(cell, label, opponent) {
const pawn = documentObject.createElement("span");
pawn.className = opponent ? "pawn pawn-opponent" : "pawn pawn-self";
const text = documentObject.createElement("span"); text.textContent = label;
pawn.appendChild(text); cell.appendChild(pawn);
function addPawn(cell, slot, opponent) {
const pawn = icon("player", `pawn ${opponent ? "pawn-opponent" : "pawn-self"} pawn-owner-${slot === 2 ? "two" : "one"}`);
cell.appendChild(pawn);
}
function characterForSlot(slot) {
return slot === 2 ? {symbol: "◆", name: "Мышонок"} : {symbol: "●", name: "Лисёнок"};
return slot === 2 ? {name: "Синий герой", keyName: "синего игрока", owner: "two"} : {name: "Жёлтый герой", keyName: "жёлтого игрока", owner: "one"};
}
function renderMap(snapshot) {
@@ -477,7 +476,7 @@
if (!(data.passages & 8)) cell.classList.add("wall-w");
(objects.get(data.index) || []).forEach(object => addObject(cell, object));
const slot = state.session ? state.session.slot : 1;
if (data.index === snapshot.opponent) addPawn(cell, characterForSlot(slot === 1 ? 2 : 1).symbol, true);
if (data.index === snapshot.opponent) addPawn(cell, slot === 1 ? 2 : 1, true);
maze.appendChild(cell);
});
byId("map-placeholder").hidden = true;
@@ -492,15 +491,13 @@
setScreen("match");
const slot = state.session && state.session.slot ? state.session.slot : 1;
const character = characterForSlot(slot);
byId("self-slot").textContent = character.symbol;
byId("self-slot").setAttribute("aria-label", character.name);
byId("avatar-handle-symbol").textContent = character.symbol;
byId("avatar-control").setAttribute("aria-label", `Тянуть героя ${character.name} для движения`);
byId("self-name").textContent = playerName();
byId("identity").className = `identity player-${slot === 1 ? "one" : "two"}`;
byId("match-screen").classList.toggle("player-two-mode", slot === 2);
byId("match-state").textContent = PHASES[snapshot.phase] || "Гонка идёт";
byId("inventory").textContent = snapshot.inventory == null ? "Пусто" : `Ключ ${characterForSlot(snapshot.inventory + 1).symbol}`;
byId("inventory").textContent = snapshot.inventory == null ? "Пусто" : `Ключ ${characterForSlot(snapshot.inventory + 1).keyName}`;
byId("inventory-stat").dataset.filled = String(snapshot.inventory != null);
byId("drop-key").disabled = snapshot.inventory == null;
setDirectionalPadEnabled(snapshot.phase === "active" && state.socket && state.socket.readyState === windowObject.WebSocket.OPEN);
@@ -561,10 +558,8 @@
(objects.get(index) || []).forEach(object => addObject(cell, object));
players.forEach((player, playerIndex) => {
if (player.cell === index && !player.exited) {
const pawn = documentObject.createElement("span");
pawn.className = `pawn pawn-player-${playerIndex === 0 ? "one" : "two"}`;
const label = documentObject.createElement("span"); label.textContent = `P${playerIndex + 1}`;
pawn.appendChild(label); cell.appendChild(pawn);
const pawn = icon("player", `pawn pawn-player-${playerIndex === 0 ? "one" : "two"} pawn-owner-${playerIndex === 0 ? "one" : "two"}`);
cell.appendChild(pawn);
}
});
maze.appendChild(cell);
@@ -582,7 +577,8 @@
else if (snapshot.phase !== "paused") hideModal();
}
function showModal(title, text, kicker, countdown) {
function showModal(title, text, kicker, countdown, iconName) {
byId("modal-icon").querySelector("use").setAttribute("href", `/icons.svg#${iconName || "hourglass"}`);
byId("modal-title").textContent = title; byId("modal-text").textContent = text; byId("modal-kicker").textContent = kicker;
byId("countdown").hidden = !countdown; byId("modal").hidden = false;
}
@@ -616,7 +612,7 @@
title = slot ? (winner === slot ? "Победа!" : "Победил соперник") : `Победил Игрок ${winner}`;
text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат.";
}
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false);
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false, "exit-open");
resetPlayerControls();
byId("modal-action").textContent = state.session && state.session.role === "player" && state.session.slot === 1 ? "Новая гонка" : "Вернуться в лобби";
byId("modal-action").hidden = false;
@@ -658,7 +654,7 @@
} else if (message.type === "error") {
state.moveFlow.complete(message.requestId);
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
if (message.payload.code === "blocked") { const bump = byId("wall-bump"); bump.textContent = localError("blocked"); bump.classList.remove("show"); void bump.offsetWidth; bump.classList.add("show"); }
if (message.payload.code === "blocked") { const bump = byId("wall-bump"); bump.querySelector(".wall-bump-text").textContent = localError("blocked"); bump.classList.remove("show"); void bump.offsetWidth; bump.classList.add("show"); }
showNotice(localError(message.payload.code), "error"); feedback(false);
}
}