feat(ui): implement responsive player browser experience

This commit is contained in:
2026-09-04 21:57:17 +03:00
parent 9339578dc9
commit d042c3ce90
17 changed files with 926 additions and 152 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
.pio/
.vscode/
.obsidian/
data/*.gz
+52 -4
View File
@@ -657,7 +657,7 @@ Complete the authoritative gameplay mechanics and terminal match outcomes.
## Milestone 008 — Player browser experience
**Status:** `NOT_STARTED`
**Status:** `IN_PROGRESS`
### Objective
@@ -705,9 +705,57 @@ into the browser.
### Evidence
Completion evidence is not yet available because this milestone has not
started. Required evidence includes browser-test output, desktop/mobile visual
checks, refresh recovery results, and filesystem image size.
Implementation is complete, but the milestone remains `IN_PROGRESS` until the
required physical-device and current-stable-browser checks can be run.
- `bash scripts/run_host_tests.sh` completed successfully. The five focused
browser tests reported keyboard/swipe direction equivalence, intention-only
command envelopes, full replacement of chunked projections without
explored-map memory, token-only refresh restoration, and local Russian,
accessible, responsive assets. All Milestone 001-007 host regressions passed
in the same run. The complete default observer projection remained bounded
at 688 chunks with a worst-case maximum of 495 bytes against the 512-byte
message limit. A separate AddressSanitizer and UndefinedBehaviorSanitizer run
of the changed projection suite passed without findings; leak detection was
disabled because the sandbox executes under `ptrace`.
- `python3 scripts/verify_web_assets.py` reported `web asset reproducibility:
PASS` with digest
`f321d5bb1b4ad8cfcdf306c5a5e696f3b2b00b54792c727fd639671e585b573d`
and a complete compressed payload of 17,100 of 1,572,864 allowed bytes.
Source inspection and tests found no remote runtime URL, import, font,
framework, analytics, or icon dependency. `node --check web/player.js` and
`xmllint --noout web/icons.svg` also completed successfully.
- `pio run -e esp32-c6-devkitm-1` completed with `[SUCCESS]`, reporting 47,508
of 327,680 RAM bytes and 1,005,660 of 2,097,152 application bytes. The
independent partition check reported `firmware.bin uses 1006288/2097152
bytes in partition factory`. `pio run -e esp32-c6-devkitm-1 --target buildfs`
completed with `[SUCCESS]`; the independent partition check reported
`littlefs.bin uses 2031616/2031616 bytes in partition littlefs`, with all five
gzip assets present in the image.
- Physical delivery passed after the board re-enumerated as `/dev/ttyACM0`.
`pio run -e esp32-c6-devkitm-1 --target upload --upload-port /dev/ttyACM0`
and the corresponding `uploadfs` command both completed with `[SUCCESS]` and
hash-verified every write. The ESP32-C6FH4 revision 0.2 boot log reported
application version `0.8.0`, the unchanged 4 MB partition table,
`LittleFS mounted: total=2031616 bytes, used=36864 bytes`, and successful HTTP,
WebSocket, and provisioning service startup. The saved station SSID remained
`k49`, so the device correctly entered its `maze32-F7B9` fallback AP while the
host remained on `Mazda7`; the host network was not changed.
- A USB readback of the complete LittleFS partition used
`/home/sasa/.platformio/penv/bin/python
/home/sasa/.platformio/packages/tool-esptoolpy/esptool.py --chip esp32c6
--port /dev/ttyACM0 read_flash 0x210000 0x1f0000
/tmp/maze32-m008-littlefs-readback.bin`. Both the built image and physical
readback had SHA-256
`606b98e11cbc479bf11bffa9a38d62ddfebce0689f0e3ebf148521190a032fd5`;
`cmp` returned zero, proving byte-for-byte delivery without interrupting the
host's internet connection.
- Live browser validation is blocked. The browser-control runtime returned `No
browser is available`, and its browser inventory was empty. Therefore the
required visual and interaction passes at `412 x 916`, representative tablet
and desktop viewports, and current stable Chrome, Safari, Firefox, and Edge
cannot yet be recorded. Milestone 008 must not be marked `COMPLETE` until
those browser checks pass. Milestone 009 remains `NOT_STARTED`.
## Milestone 009 — Observer mode and disconnect recovery
@@ -187,6 +187,8 @@ typedef struct {
uint32_t state_sequence;
uint32_t meeting_sequence;
maze32_game_final_result_t final_result;
uint16_t width;
uint16_t height;
uint16_t self_cell_index;
uint16_t opponent_cell_index;
uint16_t observer_player_cell_indices[MAZE32_GAME_PLAYER_COUNT];
+6 -3
View File
@@ -461,6 +461,8 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_
view->state_sequence = game->state_sequence;
view->meeting_sequence = game->meeting_sequence;
view->final_result = game->final_result;
view->width = game->maze->config.width;
view->height = game->maze->config.height;
view->self_carried_key = MAZE32_GAME_NO_KEY;
view->opponent_carried_key = MAZE32_GAME_NO_KEY;
view->opponent_cell_index = UINT16_MAX;
@@ -648,11 +650,12 @@ maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view,
size_t offset = 0U;
if (!append_text(output, output_size, &offset,
"{\"v\":1,\"type\":\"view\",\"seq\":%lu,\"payload\":{\"role\":\"%s\","
"\"phase\":\"%s\",\"result\":\"%s\",\"meetingSequence\":%lu,"
"\"phase\":\"%s\",\"result\":\"%s\",\"width\":%u,\"height\":%u,"
"\"meetingSequence\":%lu,"
"\"chunk\":%u,\"chunks\":%u",
(unsigned long)view->state_sequence, role_name(view->role),
phase_name(view->phase), final_result_name(view->final_result),
(unsigned long)view->meeting_sequence, (unsigned int)chunk_index,
phase_name(view->phase), final_result_name(view->final_result), view->width,
view->height, (unsigned long)view->meeting_sequence, (unsigned int)chunk_index,
(unsigned int)total_chunks)) {
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
}
+53 -11
View File
@@ -7,22 +7,37 @@
#define MAZE32_STATIC_CHUNK_SIZE 1024
static const char* TAG = "maze32_web";
static const char* INDEX_PATH = "/littlefs/index.html.gz";
typedef struct {
const char* path;
const char* content_type;
} maze32_static_asset_t;
static const maze32_static_asset_t INDEX_ASSET = {"/littlefs/index.html.gz",
"text/html; charset=utf-8"};
static const maze32_static_asset_t PLAYER_STYLE_ASSET = {"/littlefs/player.css.gz", "text/css"};
static const maze32_static_asset_t PLAYER_SCRIPT_ASSET = {"/littlefs/player.js.gz",
"text/javascript; charset=utf-8"};
static const maze32_static_asset_t ICON_ASSET = {"/littlefs/icons.svg.gz", "image/svg+xml"};
static void preserve_static_context(void* context) {
(void)context;
}
static esp_err_t send_index(httpd_req_t* request) {
FILE* file = fopen(INDEX_PATH, "rb");
static esp_err_t send_static_asset(httpd_req_t* request) {
const maze32_static_asset_t* asset = request->user_ctx;
if (asset == NULL) {
return ESP_ERR_INVALID_ARG;
}
FILE* file = fopen(asset->path, "rb");
if (file == NULL) {
ESP_LOGE(TAG, "Static asset is unavailable: %s", INDEX_PATH);
ESP_LOGE(TAG, "Static asset is unavailable: %s", asset->path);
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR,
"Приложение временно недоступно");
return ESP_FAIL;
}
httpd_resp_set_type(request, "text/html; charset=utf-8");
httpd_resp_set_type(request, asset->content_type);
httpd_resp_set_hdr(request, "Content-Encoding", "gzip");
httpd_resp_set_hdr(request, "Cache-Control", "no-cache");
@@ -37,7 +52,7 @@ static esp_err_t send_index(httpd_req_t* request) {
}
if (ferror(file) != 0) {
ESP_LOGE(TAG, "Failed while reading static asset: %s", INDEX_PATH);
ESP_LOGE(TAG, "Failed while reading static asset: %s", asset->path);
result = ESP_FAIL;
}
fclose(file);
@@ -74,7 +89,7 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
}
httpd_config_t configuration = HTTPD_DEFAULT_CONFIG();
configuration.max_uri_handlers = 9;
configuration.max_uri_handlers = 12;
configuration.max_open_sockets = 9;
configuration.lru_purge_enable = false;
configuration.recv_wait_timeout = 5;
@@ -91,14 +106,32 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
const httpd_uri_t root = {
.uri = "/",
.method = HTTP_GET,
.handler = send_index,
.user_ctx = NULL,
.handler = send_static_asset,
.user_ctx = (void*)&INDEX_ASSET,
};
const httpd_uri_t index = {
.uri = "/index.html",
.method = HTTP_GET,
.handler = send_index,
.user_ctx = NULL,
.handler = send_static_asset,
.user_ctx = (void*)&INDEX_ASSET,
};
const httpd_uri_t player_style = {
.uri = "/player.css",
.method = HTTP_GET,
.handler = send_static_asset,
.user_ctx = (void*)&PLAYER_STYLE_ASSET,
};
const httpd_uri_t player_script = {
.uri = "/player.js",
.method = HTTP_GET,
.handler = send_static_asset,
.user_ctx = (void*)&PLAYER_SCRIPT_ASSET,
};
const httpd_uri_t icons = {
.uri = "/icons.svg",
.method = HTTP_GET,
.handler = send_static_asset,
.user_ctx = (void*)&ICON_ASSET,
};
const httpd_uri_t health_uri = {
.uri = "/health",
@@ -111,6 +144,15 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
if (result == ESP_OK) {
result = httpd_register_uri_handler(*server, &index);
}
if (result == ESP_OK) {
result = httpd_register_uri_handler(*server, &player_style);
}
if (result == ESP_OK) {
result = httpd_register_uri_handler(*server, &player_script);
}
if (result == ESP_OK) {
result = httpd_register_uri_handler(*server, &icons);
}
if (result == ESP_OK) {
result = httpd_register_uri_handler(*server, &health_uri);
}
Binary file not shown.
+2 -2
View File
@@ -36,8 +36,8 @@ maze cells, static objects, and both current player positions.
The serialized view is split into bounded JSON records of at most 512 bytes,
matching the WebSocket gateway's message bound.
Every record uses the architecture's versioned envelope and carries the
authoritative state sequence, role, match phase, meeting sequence, chunk index,
and chunk count. Role status appears once in chunk zero instead of being
authoritative state sequence, role, match phase, maze width and height,
meeting sequence, chunk index, and chunk count. Role status appears once in chunk zero instead of being
duplicated across the snapshot. Cell records contain only the cell index and
current passage flags. Object records contain kind, owner, variant, and cell
index. Player records omit the `opponent` member when that player is not
+59
View File
@@ -0,0 +1,59 @@
# Player browser application
Milestone 008 provides the responsive player-facing browser application. The
browser remains a renderer and input device: it sends movement and key-drop
intentions, and it never calculates a legal move, inventory change, timer,
visibility region, compass result, or match result.
## Local assets
The application consists of `index.html`, `player.css`, `player.js`, and the
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.
## Input contract
Arrow keys and WASD map to `north`, `east`, `south`, and `west` move intentions.
Touch swipes use the same four strings after a 28 CSS-pixel threshold. Phone
layouts hide the direction pad and retain swipes; tablet and desktop layouts
show the direction pad and also accept swipes. The persistent `OK` control
sends `drop_key` and exposes the accessible label `Сбросить ключ`.
Commands use the version-one protocol envelope and contain intention data only:
```json
{"v":1,"type":"move","seq":7,"requestId":"move-7","payload":{"direction":"west"}}
```
```json
{"v":1,"type":"drop_key","seq":8,"requestId":"drop_key-8","payload":{}}
```
## Projection handling
View chunks are grouped by authoritative view sequence and rendered only after
all chunks arrive. Each completed view replaces the previous maze DOM in full.
The client does not merge a new view with previously visible cells, infer
missing passages, or retain an explored map. Maze dimensions, cells, objects,
player position, opponent visibility, inventory, vision duration, compass hint,
phase, and result all come from the server projection.
The session token is the only game-related value saved in browser storage. On
reload, the client sends that token in a `restore` intention and waits for a
fresh role-appropriate server projection. Position, inventory, effects, hints,
maze cells, and results are never restored from browser storage.
## Presentation states
Name selection, lobby, countdown, active play, pause, and match result use the
shared dark retro-arcade visual system. The fixed player status identifies the
player and labels inventory, remaining boosted-vision moves, compass direction,
and match phase. Modal overlays distinguish countdown, pause, and results.
Rejected movement produces a local wall-bump animation without revealing any
additional maze state. All player-facing copy and browser-generated errors are
Russian; protocol identifiers remain English.
Sound and supported vibration are enabled after the first pointer or keyboard
interaction. Both are progressive enhancements: unsupported devices continue
without an error.
+4
View File
@@ -78,4 +78,8 @@ cc -std=c11 -Wall -Wextra -Werror -pedantic \
-o "$test_directory/test_game_mechanics"
"$test_directory/test_game_mechanics"
node "$project_directory/test/web/test_player_app.js"
python3 "$project_directory/scripts/generate_web_assets.py" \
--source "$project_directory/web" \
--output "$project_directory/data" >/dev/null
python3 "$project_directory/scripts/verify_web_assets.py"
+18 -9
View File
@@ -12,6 +12,7 @@ from generate_web_assets import generate_assets
PROJECT_DIRECTORY = Path(__file__).resolve().parent.parent
COMPRESSED_PAYLOAD_LIMIT = 1572864
def tree_digest(directory: Path) -> str:
@@ -41,17 +42,25 @@ def main() -> int:
if first_digest != checked_in_digest:
raise AssertionError("Generated web assets are stale; regenerate the data directory")
index_source = (source_directory / "index.html").read_bytes()
index_compressed = (first / "index.html.gz").read_bytes()
if gzip.decompress(index_compressed) != index_source:
raise AssertionError("Compressed index does not reproduce its source")
for compressed_path in sorted(path for path in first.rglob("*.gz") if path.is_file()):
relative_path = compressed_path.relative_to(first)
source_relative = Path(str(relative_path)[:-3])
if gzip.decompress(compressed_path.read_bytes()) != (
source_directory / source_relative
).read_bytes():
raise AssertionError(f"Compressed asset does not reproduce {source_relative}")
provision_source = (source_directory / "provision.html").read_bytes()
provision_compressed = (first / "provision.html.gz").read_bytes()
if gzip.decompress(provision_compressed) != provision_source:
raise AssertionError("Compressed provisioning page does not reproduce its source")
compressed_size = sum(path.stat().st_size for path in first.rglob("*.gz"))
if compressed_size > COMPRESSED_PAYLOAD_LIMIT:
raise AssertionError(
f"Compressed web payload is {compressed_size} bytes and exceeds "
f"the {COMPRESSED_PAYLOAD_LIMIT}-byte limit"
)
print(f"web asset reproducibility: PASS ({first_digest})")
print(
f"web asset reproducibility: PASS ({first_digest}, "
f"{compressed_size}/{COMPRESSED_PAYLOAD_LIMIT} bytes)"
)
return 0
+1
View File
@@ -200,6 +200,7 @@ static void serialize_and_inspect(const maze32_view_t* view, int expect_hidden)
assert(payload[written] == '\0');
assert(strstr(payload, "{\"v\":1,\"type\":\"view\",\"seq\":") == payload);
assert(strstr(payload, "\"phase\":\"countdown\"") != NULL);
assert(strstr(payload, "\"width\":8,\"height\":8") != NULL);
saw_last_cell |= strstr(payload, "[63,") != NULL;
saw_hidden_key |= strstr(payload, "[\"key\",1,0,63]") != NULL;
saw_players |= strstr(payload, "\"players\":[") != NULL;
+76
View File
@@ -0,0 +1,76 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const project = path.resolve(__dirname, "../..");
const player = require(path.join(project, "web/player.js"));
test("keyboard and swipe controls produce equivalent directions", () => {
const cases = [
["ArrowUp", [50, 80, 50, 20], "north"],
["KeyD", [20, 50, 80, 50], "east"],
["ArrowDown", [50, 20, 50, 80], "south"],
["KeyA", [80, 50, 20, 50], "west"]
];
for (const [key, swipe, expected] of cases) {
assert.equal(player.directionFromKey(key), expected);
assert.equal(player.directionFromSwipe(...swipe), expected);
}
assert.equal(player.directionFromSwipe(0, 0, 12, 4), null);
});
test("command envelopes contain intentions rather than client-owned state", () => {
const move = player.createEnvelope("move", 7, "move-7", {direction: "west"});
assert.deepEqual(move, {v: 1, type: "move", seq: 7, requestId: "move-7", payload: {direction: "west"}});
assert.equal("position" in move.payload, false);
assert.deepEqual(player.createEnvelope("drop_key", 8, "drop_key-8", {}), {v: 1, type: "drop_key", seq: 8, requestId: "drop_key-8", payload: {}});
});
test("chunk assembly replaces projection cells without explored-map memory", () => {
const assembler = new player.SnapshotAssembler();
const second = {v: 1, type: "view", seq: 20, payload: {role: "player1", width: 8, height: 8, chunk: 1, chunks: 2, cells: [[10, 15]], objects: []}};
const first = {v: 1, type: "view", seq: 20, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 9, inventory: null, visionMoves: 0, hint: "none", chunk: 0, chunks: 2, cells: [[9, 15]], objects: [["key", 0, 0, 10]]}};
assert.equal(assembler.accept(second), null);
const complete = assembler.accept(first);
assert.deepEqual(player.projectedCellIndices(complete), [9, 10]);
const replacement = assembler.accept({v: 1, type: "view", seq: 21, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 18, inventory: 0, visionMoves: 9, hint: "ne", chunk: 0, chunks: 1, cells: [[18, 15]], objects: []}});
assert.deepEqual(player.projectedCellIndices(replacement), [18]);
assert.equal(replacement.cells.some(cell => cell[0] === 9 || cell[0] === 10), false);
assert.equal(assembler.accept({v: 1, type: "view", seq: 22, payload: {chunk: 0, chunks: player.MAX_VIEW_CHUNKS + 1, cells: [], objects: []}}), null);
assert.equal(player.snapshotIsValid({width: 128, height: 64, cells: [[8191, 15]], objects: []}), true);
assert.equal(player.snapshotIsValid({width: 129, height: 64, cells: [], objects: []}), false);
});
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"]);
assert.equal("position" in restore.payload, false);
assert.equal("inventory" in restore.payload, false);
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.equal((script.match(/localStorage\.setItem/g) || []).length, 1);
assert.match(script, /localStorage\.setItem\(TOKEN_KEY, message\.payload\.token\)/);
});
test("player assets are local, Russian, accessible, and responsive", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8");
const all = `${html}\n${css}\n${script}\n${icons}`;
assert.doesNotMatch(all, /(?:src|href)=["']https?:|url\(["']?https?:/i);
assert.doesNotMatch(all, /@import|googleapis|analytics/i);
assert.match(html, /lang="ru"/);
assert.match(html, /aria-label="Сбросить ключ"/);
assert.match(html, /Кусты уже|Сбор заблудившихся/);
assert.match(css, /@media \(max-width: 720px\)/);
assert.match(css, /@media \(min-width: 721px\) and \(max-width: 980px\)/);
assert.match(css, /height: calc\(100dvh - 78px\)/);
assert.match(css, /\.dpad \{ display: none; \}/);
assert.match(css, /\(hover: none\) and \(pointer: coarse\) and \(max-height: 720px\)/);
assert.match(icons, /id="key"/);
assert.match(icons, /id="maze"/);
});
+1 -1
View File
@@ -1 +1 @@
0.7.0
0.8.0
+9
View File
@@ -0,0 +1,9 @@
<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>
<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>
<symbol id="radar" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 12 20 6" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="2" fill="currentColor"/></symbol>
<symbol id="hourglass" viewBox="0 0 24 24"><path d="M6 3h12M6 21h12M7 4c0 5 4 5 5 8-1 3-5 3-5 8m10-16c0 5-4 5-5 8 1 3 5 3 5 8" fill="none" stroke="currentColor" stroke-width="2"/></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>

After

Width:  |  Height:  |  Size: 1.7 KiB

+57 -121
View File
@@ -2,129 +2,65 @@
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>maze32 — лобби</title>
<style>
:root { color-scheme: dark; font-family: ui-monospace, "Cascadia Mono", monospace; --ink:#f5edff; --bg:#090711; --panel:#1d1230; --edge:#65488a; --aqua:#42ead2; --p1:#59a9ff; --p2:#ff73bf; --ok:#72e28b; --warn:#ffd75a; --bad:#ff657a; }
* { box-sizing: border-box; }
body { min-height:100vh; margin:0; padding:max(1rem,env(safe-area-inset-top)) max(1rem,env(safe-area-inset-right)) max(1rem,env(safe-area-inset-bottom)) max(1rem,env(safe-area-inset-left)); background:radial-gradient(circle at top,#24113a,var(--bg) 55%); color:var(--ink); }
main { width:min(70rem,100%); margin:auto; }
header { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:1rem; }
h1,h2 { margin:.2rem 0; text-transform:uppercase; letter-spacing:.09em; text-shadow:2px 2px #000; }
h1 { color:var(--aqua); }
.status { padding:.45rem .7rem; border:1px solid var(--edge); background:#10091d; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(17rem,1fr)); gap:1rem; }
section { min-height:13rem; padding:1rem; border:2px solid var(--edge); box-shadow:5px 5px 0 #050307; background:var(--panel); }
.player { display:grid; grid-template-columns:2.2rem 1fr auto; gap:.7rem; align-items:center; margin:.75rem 0; padding:.75rem; border:1px solid #493563; background:#130c20; }
.p1 { border-left:5px solid var(--p1); } .p2 { border-left:5px solid var(--p2); }
.lamp { color:var(--bad); } .online .lamp { color:var(--ok); }
label { display:block; margin:.8rem 0 .3rem; }
input,button { min-height:2.8rem; border:2px solid var(--aqua); border-radius:0; background:#0c1520; color:var(--ink); font:inherit; }
input { width:100%; padding:.55rem; }
button { padding:.5rem .8rem; cursor:pointer; box-shadow:3px 3px 0 #020506; }
button:hover:not(:disabled) { background:#123a40; } button:active:not(:disabled) { transform:translate(2px,2px); box-shadow:1px 1px 0 #020506; }
button:disabled { opacity:.45; cursor:not-allowed; }
.actions { display:flex; flex-wrap:wrap; gap:.6rem; margin-top:.7rem; }
#notice { min-height:2.8rem; margin-top:1rem; padding:.7rem; border-left:4px solid var(--warn); background:#17101f; }
#notice.error { border-color:var(--bad); color:#ffc0c8; }
#notice.good { border-color:var(--ok); color:#b5ffc4; }
dialog { width:min(28rem,calc(100% - 2rem)); border:3px solid var(--aqua); background:#0b0712ef; color:var(--ink); text-align:center; }
dialog::backdrop { background:#030205d9; }
#count { margin:1rem; color:var(--warn); font-size:clamp(5rem,30vw,12rem); animation:pulse .65s steps(2,end) infinite; }
.observer-warning { display:none; color:var(--warn); }
body.observer-mode .observer-warning { display:block; }
@media (min-width:1024px) and (min-height:768px) { body.observer-mode .observer-warning { display:none; } }
@keyframes pulse { 50% { transform:scale(1.08); } }
</style>
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#090711">
<title>maze32 — побег из кустов</title>
<link rel="stylesheet" href="/player.css">
</head>
<body>
<main>
<header><h1>maze32</h1><span class="status" id="connection">Подключаем провода к воздуху…</span></header>
<div class="grid">
<section>
<h2>Игроки</h2>
<div class="player p1" id="p1"><b>P1</b><span class="name">Свободно</span><span class="lamp">●</span></div>
<div class="player p2" id="p2"><b>P2</b><span class="name">Свободно</span><span class="lamp">●</span></div>
</section>
<section>
<h2>Наблюдатели</h2>
<p><strong id="observers">0</strong> / 4 смотрят очень внимательно.</p>
<p class="observer-warning">Для режима наблюдателя нужен экран не меньше 1024 × 768.</p>
<button id="observe">Смотреть лабиринт</button>
</section>
<section>
<h2>Пульт запуска</h2>
<div id="join">
<label for="name">Позывной игрока</label>
<input id="name" maxlength="32" autocomplete="nickname" placeholder="Например, Байт-Беглец">
<div class="actions"><button id="suggest">Подкинуть имя</button><button id="play">Занять слот</button></div>
</div>
<div class="actions"><button id="start" disabled>Начать 3-2-1</button><a href="/provision"><button>Wi‑Fi</button></a></div>
</section>
</div>
<div id="notice" role="status">Сервер решает всё. Браузер только красиво моргает.</div>
<main id="app">
<header class="topbar">
<a class="brand" href="/" aria-label="maze32, на главную">MAZE<span>32</span></a>
<div class="connection-pill" id="connection" data-state="connecting" role="status"><span class="signal" aria-hidden="true"></span><span id="connection-text">Ловим сигнал ESP…</span></div>
<button class="icon-button" id="details-toggle" type="button" aria-controls="details-panel" aria-expanded="false"><svg aria-hidden="true"><use href="/icons.svg#signal"></use></svg><span class="sr-only">Открыть сведения о соединении</span></button>
</header>
<aside class="details-panel" id="details-panel" hidden><strong>Связь с коробочкой</strong><span id="details-text">Подключение ещё не готово.</span><a href="/provision">Настроить Wi‑Fi</a></aside>
<section class="screen welcome-screen" id="welcome-screen" aria-labelledby="welcome-title">
<div class="hero-art" aria-hidden="true"><span class="pixel-bush bush-one"></span><span class="pixel-bush bush-two"></span><svg><use href="/icons.svg#maze"></use></svg></div>
<div class="panel welcome-panel">
<p class="eyebrow">Локальная гонка на два носа</p>
<h1 id="welcome-title">Кусты уже<br><span>потеряли выход</span></h1>
<p>Выбирай позывной. Сервер запомнит всё важное, а браузеру доверит только красивые пиксели.</p>
<label for="player-name">Позывной игрока</label>
<input id="player-name" maxlength="32" autocomplete="nickname" placeholder="Например, Байт-Беглец">
<div class="button-row"><button class="button secondary" id="suggest-name" type="button">Подкинуть имя</button><button class="button primary" id="join-player" type="button">Занять слот</button></div>
<button class="text-button" id="join-observer" type="button">Смотреть и не трогать</button>
</div>
</section>
<section class="screen lobby-screen" id="lobby-screen" hidden aria-labelledby="lobby-title">
<div class="section-heading"><div><p class="eyebrow">Комната ожидания</p><h1 id="lobby-title">Сбор заблудившихся</h1></div><span class="phase-chip" id="lobby-phase">Ждём игроков</span></div>
<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>
</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>
</div>
</section>
<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" id="self-slot">P?</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>
</div>
<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>
<div class="wall-bump" id="wall-bump" role="status" aria-live="polite"></div>
</div>
<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>
<nav class="dpad" aria-label="Управление движением"><button type="button" data-direction="north" aria-label="Двигаться вверх">▲</button><button type="button" data-direction="west" aria-label="Двигаться влево">◀</button><span aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо">▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз">▼</button></nav>
</section>
<div class="notice" id="notice" role="status" aria-live="polite">Сервер решает всё. Мы отвечаем за драматичное мерцание.</div>
</main>
<dialog id="overlay"><h2 id="overlay-title">Приготовились!</h2><div id="count">3</div><p id="overlay-text">Сервер синхронизирует старт.</p></dialog>
<script>
(() => {
const $ = id => document.getElementById(id);
const state = { socket:null, clientSeq:0, serverSeq:0, session:null, lobby:null, reconnect:null };
const tokenKey = 'maze32.session.v1';
const notice = (text, kind='') => { $('notice').textContent=text; $('notice').className=kind; };
const send = (type,payload={},requestId=type) => {
if (!state.socket || state.socket.readyState !== WebSocket.OPEN) return;
state.socket.send(JSON.stringify({v:1,type,seq:++state.clientSeq,requestId,payload}));
};
const render = () => {
if (!state.lobby) return;
state.lobby.players.forEach(player => {
const row = $('p'+player.slot);
row.querySelector('.name').textContent = player.occupied ? player.name : 'Свободно';
row.classList.toggle('online',player.connected);
});
$('observers').textContent=state.lobby.observers;
$('join').hidden=!!state.session;
$('observe').hidden=!!state.session;
const owner=state.session?.role==='player' && state.session.slot===1;
$('start').disabled=!(owner && state.lobby.ready && state.lobby.phase==='waiting');
document.body.classList.toggle('observer-mode',state.session?.role==='observer');
};
const countdown = () => {
const dialog=$('overlay'); $('overlay-title').textContent='Приготовились!'; $('overlay-text').textContent='Один сервер — один момент старта.';
if (!dialog.open) dialog.showModal();
let value=3; $('count').textContent=value;
const timer=setInterval(() => { value--; if (value>0) $('count').textContent=value; else clearInterval(timer); },1000);
};
const handle = event => {
let message; try { message=JSON.parse(event.data); } catch { notice('Сервер прислал нечитаемый пакет.','error'); return; }
if (message.v!==1 || !Number.isInteger(message.seq) || message.seq<=state.serverSeq) return;
state.serverSeq=message.seq;
if (message.type==='session') {
state.session=message.payload; localStorage.setItem(tokenKey,message.payload.token);
notice(message.payload.role==='player' ? `Слот P${message.payload.slot} закреплён. Токен спрятан в этом браузере.` : 'Вы в зрительном зале. Руками не трогаем!','good');
} else if (message.type==='lobby') { state.lobby=message.payload; render();
} else if (message.type==='name_suggestion') { $('name').value=message.payload.name;
} else if (message.type==='countdown') { countdown();
} else if (message.type==='active') { $('count').textContent='GO'; $('overlay-title').textContent='Старт!'; $('overlay-text').textContent='Лобби активировано. Лабиринт появится в следующем этапе.'; setTimeout(()=>$('overlay').close(),1200);
} else if (message.type==='error') {
if (message.payload.code==='invalid_token') { localStorage.removeItem(tokenKey); state.session=null; render(); }
notice(message.payload.message || 'Команда отклонена сервером.','error');
}
};
const connect = () => {
clearTimeout(state.reconnect); state.clientSeq=0; state.serverSeq=0;
const scheme=location.protocol==='https:'?'wss':'ws'; state.socket=new WebSocket(`${scheme}://${location.host}/ws`);
state.socket.onopen=() => { $('connection').textContent='Связь с ESP: есть'; const token=localStorage.getItem(tokenKey); if(token) send('restore',{token},'refresh'); };
state.socket.onmessage=handle;
state.socket.onclose=() => { $('connection').textContent='Связь потеряна, переподключаемся…'; state.reconnect=setTimeout(connect,1000); };
state.socket.onerror=() => notice('WebSocket споткнулся. Пробуем снова…','error');
};
$('suggest').onclick=()=>send('suggest_name',{},'suggest');
$('play').onclick=()=>send('join',{role:'player',name:$('name').value},'join-player');
$('observe').onclick=()=>send('join',{role:'observer'},'join-observer');
$('start').onclick=()=>send('start',{},'start');
connect();
})();
</script>
<div class="modal" id="modal" hidden role="dialog" aria-modal="true" aria-labelledby="modal-title"><div class="modal-card"><svg id="modal-icon" aria-hidden="true"><use href="/icons.svg#hourglass"></use></svg><p class="eyebrow" id="modal-kicker">Серверное объявление</p><h2 id="modal-title">Приготовились!</h2><div class="countdown" id="countdown" hidden>3</div><p id="modal-text">Один сервер — один честный момент.</p><button class="button primary" id="modal-action" type="button" hidden>Вернуться в лобби</button></div></div>
<script src="/player.js" defer></script>
</body>
</html>
+220
View File
@@ -0,0 +1,220 @@
:root {
color-scheme: dark;
font-family: ui-monospace, "Cascadia Mono", "SFMono-Regular", Menlo, Consolas, monospace;
--bg: #090711;
--panel: #1d1230;
--panel-deep: #110a20;
--edge: #674990;
--ink: #f6efff;
--muted: #b7a8ca;
--aqua: #43ead2;
--p1: #58aaff;
--p2: #ff72bd;
--plant: #45b86b;
--plant-dark: #176638;
--success: #72e48c;
--warning: #ffd65a;
--error: #ff647a;
--pause: #5598ff;
--disconnect: #ff9f43;
--cell: 40px;
--shadow: #040207;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); }
body { overflow-x: hidden; background-image: radial-gradient(circle at 50% -20%, #33194c 0, transparent 42%), linear-gradient(#090711dd, #090711f5), repeating-linear-gradient(0deg, transparent 0 3px, #ffffff05 3px 4px); }
button, input { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
[hidden] { display: none !important; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
#app { min-height: 100dvh; width: min(1180px, 100%); margin: 0 auto; padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); }
.topbar { position: relative; z-index: 20; min-height: 54px; display: flex; align-items: center; gap: 12px; border-bottom: 2px solid #342347; }
.brand { color: var(--ink); text-decoration: none; font-weight: 1000; font-size: clamp(1.1rem, 3vw, 1.45rem); letter-spacing: .14em; text-shadow: 3px 3px 0 #000; }
.brand span { color: var(--aqua); }
.connection-pill { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; }
.signal { width: 9px; height: 9px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 10px var(--warning); }
.connection-pill[data-state="online"] .signal { background: var(--success); box-shadow: 0 0 10px var(--success); }
.connection-pill[data-state="offline"] .signal { background: var(--disconnect); box-shadow: 0 0 10px var(--disconnect); }
.icon-button { width: 38px; height: 38px; padding: 8px; border: 1px solid var(--edge); background: var(--panel-deep); cursor: pointer; }
.icon-button svg { width: 100%; height: 100%; color: var(--aqua); }
.details-panel { position: absolute; z-index: 30; top: 60px; right: max(12px, env(safe-area-inset-right)); display: grid; gap: 6px; width: min(300px, calc(100% - 24px)); padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; }
.details-panel span { color: var(--muted); }
.details-panel a { color: var(--aqua); }
.screen { min-height: calc(100dvh - 90px); animation: screen-in .28s steps(5, end); }
.welcome-screen { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 500px); align-items: center; gap: clamp(24px, 6vw, 90px); padding: 5vh 4vw; }
.hero-art { position: relative; min-height: 330px; display: grid; place-items: center; color: var(--aqua); }
.hero-art svg { width: min(100%, 460px); filter: drop-shadow(10px 10px 0 #030204); animation: hover 2.4s steps(4, end) infinite; }
.pixel-bush { position: absolute; width: 76px; height: 56px; background: var(--plant); box-shadow: 18px -18px 0 var(--plant), 36px 0 0 var(--plant), 18px 18px 0 var(--plant-dark); image-rendering: pixelated; opacity: .8; }
.bush-one { left: 3%; bottom: 12%; transform: scale(.8); }
.bush-two { right: 4%; top: 12%; transform: scale(.55); }
.panel { border: 2px solid var(--edge); background: linear-gradient(145deg, #25163a, var(--panel)); box-shadow: 7px 7px 0 var(--shadow); }
.welcome-panel { padding: clamp(22px, 4vw, 42px); }
.eyebrow { margin: 0 0 8px; color: var(--aqua); text-transform: uppercase; letter-spacing: .12em; font-size: .72rem; }
h1, h2, p { text-wrap: balance; }
h1, h2 { margin: 0; text-transform: uppercase; line-height: 1.04; text-shadow: 3px 3px 0 #000; }
h1 { font-size: clamp(2rem, 6vw, 4.6rem); }
h1 span { color: var(--warning); }
h2 { font-size: 1rem; letter-spacing: .06em; }
p { color: var(--muted); line-height: 1.55; }
label { display: block; margin: 24px 0 7px; color: var(--ink); font-size: .8rem; font-weight: 800; text-transform: uppercase; }
input { width: 100%; min-height: 50px; border: 2px solid var(--edge); border-radius: 0; outline: 0; background: #0c0815; padding: 0 14px; color: var(--ink); }
input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.button-row { display: flex; gap: 10px; margin-top: 12px; }
.button { min-height: 48px; padding: 0 17px; border: 2px solid var(--aqua); border-radius: 0; background: #0f2026; cursor: pointer; font-weight: 900; text-transform: uppercase; box-shadow: 4px 4px 0 var(--shadow); }
.button:hover:not(:disabled) { background: #164049; }
.button:active:not(:disabled) { transform: translate(3px, 3px); box-shadow: 1px 1px 0 var(--shadow); }
.button:disabled { opacity: .4; cursor: not-allowed; }
.button.primary { background: var(--aqua); color: #051411; }
.button.primary:hover:not(:disabled) { background: #87f6e6; }
.button.pending, .dpad button.pending, .drop-key.pending { animation: pending .5s steps(2, end) infinite; }
.text-button { margin-top: 20px; border: 0; background: transparent; color: var(--muted); text-decoration: underline; cursor: pointer; }
.lobby-screen { padding: clamp(28px, 5vw, 70px) 2vw; }
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 18px; margin-bottom: 24px; }
.section-heading h1 { font-size: clamp(1.8rem, 5vw, 3.8rem); }
.phase-chip { padding: 9px 12px; border: 1px solid var(--warning); color: var(--warning); background: #2b220b; font-size: .72rem; text-transform: uppercase; }
.lobby-grid { display: grid; grid-template-columns: 1.4fr .8fr; gap: 18px; }
.lobby-grid .panel { padding: 22px; }
.roster-panel { grid-row: span 2; }
.player-card { display: grid; grid-template-columns: 54px 1fr 12px; align-items: center; gap: 13px; margin-top: 16px; padding: 14px; border: 1px solid #51386e; background: #100a1b; }
.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; }
.player-one .avatar, .identity.player-one .avatar { color: var(--p1); }
.player-two .avatar, .identity.player-two .avatar { color: var(--p2); }
.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); }
.player-card.online .status-dot { background: var(--success); box-shadow: 0 0 9px var(--success); }
.observer-panel svg { width: 46px; height: 46px; color: var(--p2); }
.observer-panel strong { color: var(--p2); font-size: 1.7rem; }
.launch-button { width: 100%; margin-top: 10px; }
.match-screen { position: relative; height: calc(100dvh - 78px); min-height: 520px; overflow: hidden; padding-top: 84px; }
.player-status { position: absolute; z-index: 8; inset: 10px 0 auto; display: grid; grid-template-columns: minmax(190px, 1.4fr) repeat(3, minmax(120px, .7fr)); gap: 7px; }
.identity, .stat { min-height: 68px; display: flex; align-items: center; gap: 10px; padding: 8px 12px; border: 1px solid #543a72; background: #171027eb; backdrop-filter: blur(8px); }
.identity .avatar { flex: 0 0 46px; }
.stat svg { width: 29px; height: 29px; flex: 0 0 29px; color: var(--aqua); }
.stat strong { font-size: .75rem; }
.vision-active { border-color: var(--warning); box-shadow: inset 0 0 18px #ffd65a22; }
.vision-active svg { color: var(--warning); animation: eye-pulse 1s steps(3, end) infinite; }
.compass-stat[data-direction]:not([data-direction="none"]) { border-color: var(--aqua); }
#compass-arrow { transition: transform .15s steps(3, end); }
.map-shell { position: absolute; inset: 84px 0 0; overflow: hidden; outline: 0; border: 2px solid #2f2041; background-color: #050408; background-image: radial-gradient(circle at center, #161024 0, #050408 72%); touch-action: none; user-select: none; }
.map-shell:focus-visible { border-color: var(--aqua); }
.unknown-message { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 12px; padding: 24px; color: var(--muted); text-align: center; }
.unknown-message svg { width: 54px; height: 54px; color: var(--aqua); animation: radar 1.8s linear infinite; }
.maze { position: absolute; left: 50%; top: 50%; display: grid; transform: translate(-50%, -50%); }
.cell { position: relative; width: var(--cell); height: var(--cell); background: #11131a; border: 4px solid transparent; }
.cell.wall-n { border-top-color: var(--plant); box-shadow: inset 0 3px 0 var(--plant-dark); }
.cell.wall-e { border-right-color: var(--plant); }
.cell.wall-s { border-bottom-color: var(--plant); }
.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: attr(data-owner); position: absolute; right: -5px; bottom: -6px; padding: 1px 3px; background: #090711; border: 1px solid currentColor; font-size: 8px; font-weight: 900; }
.object-exit { inset: 8%; border: 3px double currentColor; background: #0b0911; }
.object-exit[data-owner="P1"] { color: var(--p1); }
.object-exit[data-owner="P2"] { color: var(--p2); }
.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); }
.pawn-self { color: var(--p1); }
.match-screen.player-two-mode .pawn-self { color: var(--p2); }
.pawn-opponent { color: var(--p2); transform: rotate(45deg); }
.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; }
.wall-bump.show { animation: bump .8s steps(3, end); }
.drop-key { position: fixed; z-index: 12; right: max(22px, env(safe-area-inset-right)); top: max(166px, calc(env(safe-area-inset-top) + 150px)); width: 78px; height: 78px; display: grid; grid-template-columns: 26px 1fr; place-content: center; align-items: center; border: 3px solid var(--warning); background: #392c0d; color: var(--warning); box-shadow: 5px 5px 0 #050301; cursor: pointer; }
.drop-key svg { width: 25px; height: 25px; }
.drop-key strong { font-size: 1.2rem; }
.drop-key span { grid-column: 1 / -1; font-size: .56rem; text-transform: uppercase; }
.drop-key:disabled { opacity: .48; }
.dpad { position: fixed; z-index: 11; right: max(24px, env(safe-area-inset-right)); bottom: max(28px, env(safe-area-inset-bottom)); display: grid; grid-template-columns: repeat(3, 58px); grid-template-rows: repeat(2, 58px); gap: 5px; }
.dpad button { border: 2px solid var(--aqua); background: #102a30df; color: var(--aqua); box-shadow: 4px 4px 0 #020506; cursor: pointer; }
.dpad button:first-child { grid-column: 2; }
.dpad button:nth-child(2) { grid-column: 1; grid-row: 2; }
.dpad span { grid-column: 2; grid-row: 2; display: grid; place-items: center; color: #4b3a60; }
.dpad button:nth-child(4) { grid-column: 3; grid-row: 2; }
.dpad button:last-child { grid-column: 2; grid-row: 3; }
.notice { position: fixed; z-index: 40; left: 50%; bottom: max(12px, env(safe-area-inset-bottom)); max-width: calc(100% - 24px); transform: translate(-50%, 130%); padding: 10px 14px; border-left: 4px solid var(--warning); background: #191020f2; color: var(--ink); box-shadow: 4px 4px 0 #020104; font-size: .76rem; opacity: 0; transition: .18s steps(3, end); pointer-events: none; }
.notice.visible { opacity: 1; transform: translate(-50%, 0); }
.notice.error { border-color: var(--error); }
.notice.success { border-color: var(--success); }
.modal { position: fixed; z-index: 50; inset: 0; display: grid; place-items: center; padding: 18px; background: #050308dc; backdrop-filter: blur(5px); }
.modal-card { width: min(440px, 100%); padding: 30px; border: 3px solid var(--aqua); background: #130b22; text-align: center; box-shadow: 9px 9px 0 #020103; animation: modal-in .28s steps(4, end); }
.modal-card > svg { width: 54px; height: 54px; color: var(--aqua); }
.modal-card h2 { font-size: clamp(1.5rem, 7vw, 2.7rem); }
.countdown { color: var(--warning); font-size: clamp(5rem, 24vw, 10rem); font-weight: 1000; line-height: 1; text-shadow: 7px 7px 0 #000; animation: count .65s steps(2, end) infinite; }
@media (max-width: 720px) {
:root { --cell: 20px; }
#app { padding-left: max(8px, env(safe-area-inset-left)); padding-right: max(8px, env(safe-area-inset-right)); }
.welcome-screen { grid-template-columns: 1fr; padding: 28px 2px; }
.hero-art { display: none; }
.welcome-panel { padding: 22px 18px; }
.button-row { flex-direction: column; }
.lobby-grid { grid-template-columns: 1fr; }
.lobby-screen { padding: 18px 2px; }
.lobby-grid { gap: 10px; }
.lobby-grid .panel { padding: 14px; }
.lobby-screen .section-heading { margin-bottom: 12px; }
.player-card { margin-top: 8px; padding: 8px; }
.roster-panel { grid-row: auto; }
.section-heading { align-items: start; flex-direction: column; }
.match-screen { height: calc(100dvh - 70px); padding-top: 116px; }
.player-status { grid-template-columns: 1.4fr repeat(3, 1fr); inset: 6px 0 auto; }
.identity, .stat { min-width: 0; min-height: 104px; padding: 7px; flex-direction: column; justify-content: center; text-align: center; gap: 2px; }
.identity .avatar { flex-basis: 38px; width: 38px; height: 38px; }
.identity strong { max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .66rem; }
.identity small, .stat small { font-size: .5rem; }
.stat strong { font-size: .58rem; }
.stat svg { width: 23px; height: 23px; flex-basis: 23px; }
.map-shell { inset: 116px 0 0; }
.drop-key { top: auto; right: max(14px, env(safe-area-inset-right)); bottom: max(18px, env(safe-area-inset-bottom)); width: 72px; height: 72px; }
.dpad { display: none; }
.connection-pill { font-size: .58rem; }
.connection-pill #connection-text { max-width: 145px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
@media (min-width: 721px) and (max-width: 980px) {
:root { --cell: 34px; }
.player-status { grid-template-columns: 1.2fr repeat(3, .7fr); }
}
@media (orientation: landscape) and (max-height: 620px) {
.topbar { min-height: 44px; }
.match-screen { height: calc(100dvh - 68px); padding-top: 64px; }
.player-status { inset: 5px 98px auto 0; }
.identity, .stat { min-height: 55px; }
.map-shell { inset: 64px 0 0; }
.drop-key { top: 76px; right: 14px; width: 70px; height: 70px; }
}
@media (hover: none) and (pointer: coarse) and (max-height: 720px) {
.dpad { display: none; }
.drop-key { top: auto; bottom: max(14px, env(safe-area-inset-bottom)); }
}
@keyframes screen-in { from { opacity: 0; transform: translateY(10px); } }
@keyframes hover { 50% { transform: translateY(-8px); } }
@keyframes pending { 50% { filter: brightness(1.8); } }
@keyframes eye-pulse { 50% { transform: scale(1.15); } }
@keyframes radar { to { transform: rotate(360deg); } }
@keyframes artifact { 50% { transform: scale(.75); box-shadow: 0 0 18px var(--warning); } }
@keyframes exit-open { 50% { outline: 3px solid var(--success); } }
@keyframes pawn-in { from { transform: scale(.6); } }
@keyframes bump { 0%, 100% { opacity: 0; } 20%, 75% { opacity: 1; transform: translateX(-50%) translateX(4px); } 45% { transform: translateX(-50%) translateX(-4px); } }
@keyframes modal-in { from { opacity: 0; transform: scale(.82); } }
@keyframes count { 50% { transform: scale(1.08); } }
+365
View File
@@ -0,0 +1,365 @@
(function (root) {
"use strict";
const TOKEN_KEY = "maze32.session.v1";
const MAX_VIEW_CHUNKS = 700;
const MAX_VIEW_CELLS = 8192;
const MAX_VIEW_OBJECTS = 30;
const KEY_DIRECTIONS = Object.freeze({
ArrowUp: "north", KeyW: "north",
ArrowRight: "east", KeyD: "east",
ArrowDown: "south", KeyS: "south",
ArrowLeft: "west", KeyA: "west"
});
const HINTS = Object.freeze({none: "Молчит", n: "Север", ne: "Северо-восток", e: "Восток", se: "Юго-восток", s: "Юг", sw: "Юго-запад", w: "Запад", nw: "Северо-запад"});
const HINT_ANGLES = Object.freeze({none: 0, n: 0, ne: 45, e: 90, se: 135, s: 180, sw: 225, w: 270, nw: 315});
const PHASES = Object.freeze({countdown: "Обратный отсчёт", waiting: "Ждём игроков", active: "Гонка идёт", finish_pending: "Фотофиниш", paused: "Пауза", finished: "Финиш"});
const ERRORS = Object.freeze({invalid_name: "Позывной должен быть длиной от 1 до 32 символов без пробелов по краям.", duplicate_name: "Этот позывной уже занят. Клоны сегодня не участвуют.", role_full: "Свободных мест больше нет.", invalid_token: "Старая сессия не найдена. Выбираем новый позывной.", session_connected: "Эта сессия уже открыта в другом окне.", not_owner: "Запустить гонку может только Игрок 1.", not_ready: "Для старта нужны оба игрока онлайн.", invalid_phase: "Сейчас эту команду выполнять нельзя.", rate_limited: "Слишком быстро! Сервер просит немного меньше турбо.", blocked: "Там стена. Очень убедительная.", no_key: "Карман пуст — бросать нечего.", player_exited: "Герой уже снаружи. Возвращаться за тапочками поздно.", match_finished: "Гонка уже закончилась.", stale_sequence: "Команда устарела и отправилась в музей.", unauthorized: "Сначала выберите роль или восстановите сессию.", text_required: "Сервер принимает только текстовые команды.", protocol_error: "Пакет не прошёл проверку протокола.", request_rejected: "Сервер отклонил команду."});
function directionFromKey(code) {
return KEY_DIRECTIONS[code] || null;
}
function directionFromSwipe(startX, startY, endX, endY, minimumDistance) {
const dx = endX - startX;
const dy = endY - startY;
const threshold = minimumDistance == null ? 28 : minimumDistance;
if (Math.max(Math.abs(dx), Math.abs(dy)) < threshold) {
return null;
}
if (Math.abs(dx) > Math.abs(dy)) {
return dx > 0 ? "east" : "west";
}
return dy > 0 ? "south" : "north";
}
function createEnvelope(type, sequence, requestId, payload) {
return {v: 1, type, seq: sequence, requestId, payload: payload || {}};
}
function snapshotIsValid(snapshot) {
if (!Number.isInteger(snapshot.width) || snapshot.width < 1 || !Number.isInteger(snapshot.height) || snapshot.height < 1 || snapshot.width * snapshot.height > MAX_VIEW_CELLS) {
return false;
}
const cellLimit = snapshot.width * snapshot.height;
return snapshot.cells.every(cell => Array.isArray(cell) && cell.length === 2 && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 15) && snapshot.objects.every(object => Array.isArray(object) && object.length === 4 && Number.isInteger(object[3]) && object[3] >= 0 && object[3] < cellLimit);
}
class SnapshotAssembler {
constructor() {
this.pending = null;
}
accept(message) {
if (!message || message.v !== 1 || message.type !== "view" || !Number.isInteger(message.seq)) {
return null;
}
const payload = message.payload;
if (!payload || !Number.isInteger(payload.chunk) || !Number.isInteger(payload.chunks) || payload.chunks < 1 || payload.chunks > MAX_VIEW_CHUNKS || payload.chunk < 0 || payload.chunk >= payload.chunks || !Array.isArray(payload.cells) || payload.cells.length > 12 || !Array.isArray(payload.objects) || payload.objects.length > 6) {
return null;
}
if (!this.pending || this.pending.seq !== message.seq || this.pending.count !== payload.chunks) {
this.pending = {seq: message.seq, count: payload.chunks, parts: new Array(payload.chunks)};
}
this.pending.parts[payload.chunk] = payload;
if (this.pending.parts.filter(Boolean).length !== this.pending.count) {
return null;
}
const first = this.pending.parts[0];
const snapshot = Object.assign({}, first, {
cells: this.pending.parts.flatMap(part => part.cells),
objects: this.pending.parts.flatMap(part => part.objects)
});
if (snapshot.cells.length > MAX_VIEW_CELLS || snapshot.objects.length > MAX_VIEW_OBJECTS || !snapshotIsValid(snapshot)) {
this.pending = null;
return null;
}
delete snapshot.chunk;
delete snapshot.chunks;
this.pending = null;
return snapshot;
}
}
function projectedCellIndices(snapshot) {
return snapshot && Array.isArray(snapshot.cells) ? snapshot.cells.map(cell => cell[0]) : [];
}
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, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), audio: null, feedbackReady: false};
function showNotice(text, kind) {
const element = byId("notice");
element.textContent = text;
element.className = `notice visible ${kind || ""}`;
windowObject.clearTimeout(state.noticeTimer);
state.noticeTimer = windowObject.setTimeout(() => element.classList.remove("visible"), 2600);
}
function setScreen(name) {
byId("welcome-screen").hidden = name !== "welcome";
byId("lobby-screen").hidden = name !== "lobby";
byId("match-screen").hidden = name !== "match";
}
function setConnection(connectionState, text) {
byId("connection").dataset.state = connectionState;
byId("connection-text").textContent = text;
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
}
function beginFeedback() {
if (state.feedbackReady) return;
state.feedbackReady = true;
const AudioContext = windowObject.AudioContext || windowObject.webkitAudioContext;
if (AudioContext) {
try { state.audio = new AudioContext(); } catch (_) { state.audio = null; }
}
}
function feedback(success) {
if (windowObject.navigator && typeof windowObject.navigator.vibrate === "function") {
windowObject.navigator.vibrate(success ? 18 : [24, 35, 24]);
}
if (!state.audio || state.audio.state === "suspended") return;
const oscillator = state.audio.createOscillator();
const gain = state.audio.createGain();
oscillator.frequency.value = success ? 560 : 150;
oscillator.type = "square";
gain.gain.setValueAtTime(.035, state.audio.currentTime);
gain.gain.exponentialRampToValueAtTime(.001, state.audio.currentTime + .06);
oscillator.connect(gain).connect(state.audio.destination);
oscillator.start(); oscillator.stop(state.audio.currentTime + .065);
}
function setPending(requestId, element) {
if (!requestId || !element) return;
state.pending.set(requestId, element);
element.classList.add("pending");
}
function clearPending(requestId) {
const element = state.pending.get(requestId);
if (element) element.classList.remove("pending");
state.pending.delete(requestId);
}
function send(type, payload, element) {
if (!state.socket || state.socket.readyState !== windowObject.WebSocket.OPEN) {
showNotice("Нет связи с ESP. Команда осталась дома.", "error");
return null;
}
const sequence = ++state.clientSequence;
const requestId = `${type}-${sequence}`;
state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload)));
setPending(requestId, element);
return requestId;
}
function playerName() {
if (!state.session || !state.lobby || state.session.role !== "player") return "Неизвестный герой";
const player = state.lobby.players.find(item => item.slot === state.session.slot);
return player && player.name ? player.name : `Игрок ${state.session.slot}`;
}
function renderLobby() {
if (!state.lobby) return;
state.lobby.players.forEach(player => {
const card = byId(`player-${player.slot}`);
card.querySelector(".player-name").textContent = player.occupied ? player.name : "Свободно";
card.querySelector(".player-state").textContent = player.connected ? "На связи" : player.occupied ? "Отошёл искать Wi‑Fi" : "Слот грустит";
card.classList.toggle("online", !!player.connected);
});
byId("observer-count").textContent = state.lobby.observers;
byId("lobby-phase").textContent = PHASES[state.lobby.phase] || "Сервер размышляет";
const owner = state.session && state.session.role === "player" && state.session.slot === 1;
byId("start-match").disabled = !(owner && state.lobby.ready && state.lobby.phase === "waiting");
byId("launch-help").textContent = owner ? (state.lobby.ready ? "Оба героя здесь. Можно будить лабиринт." : "Ждём второго героя. Первый пока разминает стрелки.") : "Старт доступен Игроку 1, когда оба слота на месте.";
if (state.session && state.lobby.phase !== "active") setScreen("lobby");
}
function icon(name, className) {
const svg = documentObject.createElementNS("http://www.w3.org/2000/svg", "svg");
const use = documentObject.createElementNS("http://www.w3.org/2000/svg", "use");
use.setAttribute("href", `/icons.svg#${name}`);
svg.appendChild(use);
const wrapper = documentObject.createElement("span");
wrapper.className = className;
wrapper.appendChild(svg);
return wrapper;
}
function addObject(cell, object) {
const [kind, owner, variant] = object;
let marker;
if (kind === "key") {
marker = icon("key", "object object-key"); marker.dataset.owner = `P${owner + 1}`;
} else if (kind === "exit") {
marker = documentObject.createElement("span"); marker.className = "object object-exit"; marker.dataset.owner = `P${owner + 1}`; 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");
} 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 renderMap(snapshot) {
const maze = byId("maze");
maze.replaceChildren();
if (!Number.isInteger(snapshot.width) || snapshot.width < 1 || !Number.isInteger(snapshot.height) || snapshot.height < 1 || !snapshot.cells.length) {
byId("map-placeholder").hidden = false;
return;
}
const coordinates = snapshot.cells.map(cell => ({index: cell[0], x: cell[0] % snapshot.width, y: Math.floor(cell[0] / snapshot.width), passages: cell[1]}));
const minX = Math.min(...coordinates.map(cell => cell.x)); const maxX = Math.max(...coordinates.map(cell => cell.x));
const minY = Math.min(...coordinates.map(cell => cell.y)); const maxY = Math.max(...coordinates.map(cell => cell.y));
maze.style.gridTemplateColumns = `repeat(${maxX - minX + 1}, var(--cell))`;
maze.style.gridTemplateRows = `repeat(${maxY - minY + 1}, var(--cell))`;
const objects = new Map();
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
coordinates.forEach(data => {
const cell = documentObject.createElement("span");
cell.className = "cell";
cell.style.gridColumn = String(data.x - minX + 1); cell.style.gridRow = String(data.y - minY + 1);
if (!(data.passages & 1)) cell.classList.add("wall-n");
if (!(data.passages & 2)) cell.classList.add("wall-e");
if (!(data.passages & 4)) cell.classList.add("wall-s");
if (!(data.passages & 8)) cell.classList.add("wall-w");
(objects.get(data.index) || []).forEach(object => addObject(cell, object));
if (data.index === snapshot.self) addPawn(cell, `P${state.session ? state.session.slot : "?"}`, false);
if (data.index === snapshot.opponent) addPawn(cell, "СОП", true);
maze.appendChild(cell);
});
byId("map-placeholder").hidden = true;
}
function renderSnapshot(snapshot) {
state.snapshot = snapshot;
setScreen("match");
const slot = state.session && state.session.slot ? state.session.slot : 1;
byId("self-slot").textContent = `P${slot}`;
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 ? "Пусто" : `Ключ P${snapshot.inventory + 1}`;
byId("drop-key").disabled = snapshot.inventory == null;
byId("vision").textContent = snapshot.visionMoves ? `${snapshot.visionMoves} ход.` : "Нет";
byId("vision-stat").classList.toggle("vision-active", snapshot.visionMoves > 0);
const hint = HINTS[snapshot.hint] ? snapshot.hint : "none";
byId("compass").textContent = HINTS[hint];
byId("compass-stat").dataset.direction = hint;
byId("compass-arrow").style.transform = `rotate(${HINT_ANGLES[hint]}deg)`;
renderMap(snapshot);
if (snapshot.phase === "paused") showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false);
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
else hideModal();
}
function showModal(title, text, kicker, countdown) {
byId("modal-title").textContent = title; byId("modal-text").textContent = text; byId("modal-kicker").textContent = kicker;
byId("countdown").hidden = !countdown; byId("modal").hidden = false;
}
function hideModal() { byId("modal").hidden = true; byId("countdown").hidden = true; }
function renderResult(result) {
const slot = state.session && state.session.slot;
let title = "Ничья!"; let text = "Фотофиниш пожал плечами: оба выбрались почти одновременно.";
if (result === "player1" || result === "player2") {
const winner = result === "player1" ? 1 : 2;
title = winner === slot ? "Победа!" : "Победил соперник";
text = winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.";
}
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false);
byId("modal-action").hidden = false;
}
function showCountdown(payload) {
showModal("Приготовились!", "Один сервер — один честный момент старта.", "СИНХРОННЫЙ СТАРТ", true);
const steps = payload && Array.isArray(payload.steps) ? payload.steps : [3, 2, 1];
let index = 0; byId("countdown").textContent = steps[index];
const timer = windowObject.setInterval(() => { index++; if (index < steps.length) byId("countdown").textContent = steps[index]; else windowObject.clearInterval(timer); }, 1000);
}
function localError(code) { return ERRORS[code] || "Сервер отклонил команду. Подробности остались у него."; }
function handleMessage(message) {
if (!message || message.v !== 1 || !Number.isInteger(message.seq)) return;
if (message.type === "view") {
if (message.seq < state.viewSequence) return;
state.viewSequence = Math.max(state.viewSequence, message.seq);
const snapshot = state.assembler.accept(message);
if (snapshot) renderSnapshot(snapshot);
return;
}
if (message.seq <= state.serverSequence) return;
state.serverSequence = message.seq;
clearPending(message.requestId);
if (message.type === "session") {
state.session = message.payload; windowObject.localStorage.setItem(TOKEN_KEY, message.payload.token);
showNotice(message.payload.role === "player" ? `Слот P${message.payload.slot} закреплён за этим браузером.` : "Вы в зрительном зале. Руками не трогаем!", "success");
if (state.lobby) renderLobby();
} else if (message.type === "lobby") { state.lobby = message.payload; renderLobby();
} else if (message.type === "name_suggestion") { byId("player-name").value = message.payload.name;
} else if (message.type === "countdown") { showCountdown(message.payload);
} else if (message.type === "active") { setScreen("match"); byId("countdown").textContent = "ВПЕРЁД"; windowObject.setTimeout(hideModal, 750);
} else if (message.type === "pause") { showModal("Пауза", "Второй герой переподключается. Ноги пока не считаются.", "ЖДЁМ СВЯЗЬ", false);
} else if (message.type === "match_result") { renderResult(message.payload.result);
} else if (message.type === "ack") { feedback(true);
} else if (message.type === "error") {
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"); }
showNotice(localError(message.payload.code), "error"); feedback(false);
}
}
function connect() {
windowObject.clearTimeout(state.reconnectTimer); state.clientSequence = 0; state.serverSequence = 0; state.viewSequence = -1; state.assembler = new SnapshotAssembler();
const scheme = windowObject.location.protocol === "https:" ? "wss" : "ws";
state.socket = new windowObject.WebSocket(`${scheme}://${windowObject.location.host}/ws`);
state.socket.onopen = () => { setConnection("online", "ESP на связи"); const token = windowObject.localStorage.getItem(TOKEN_KEY); if (token) send("restore", {token}, null); };
state.socket.onmessage = event => { let message; try { message = JSON.parse(event.data); } catch (_) { showNotice("Сервер прислал нечитаемый пакет.", "error"); return; } handleMessage(message); };
state.socket.onclose = () => { setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
state.socket.onerror = () => showNotice("WebSocket споткнулся. Пробуем снова…", "error");
}
function move(direction, element) {
if (direction) send("move", {direction}, element || documentObject.querySelector(`[data-direction="${direction}"]`));
}
documentObject.addEventListener("pointerdown", beginFeedback, {once: true});
documentObject.addEventListener("keydown", event => {
if (event.target instanceof windowObject.HTMLInputElement || byId("match-screen").hidden) return;
beginFeedback();
const direction = directionFromKey(event.code); if (direction) { event.preventDefault(); move(direction, null); }
});
byId("map-shell").addEventListener("touchstart", event => { const touch = event.changedTouches[0]; state.touchStart = {x: touch.clientX, y: touch.clientY}; }, {passive: true});
byId("map-shell").addEventListener("touchend", event => { if (!state.touchStart) return; const touch = event.changedTouches[0]; move(directionFromSwipe(state.touchStart.x, state.touchStart.y, touch.clientX, touch.clientY), null); state.touchStart = null; }, {passive: true});
documentObject.querySelectorAll("[data-direction]").forEach(button => button.addEventListener("click", () => move(button.dataset.direction, button)));
byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key")));
byId("suggest-name").addEventListener("click", () => send("suggest_name", {}, byId("suggest-name")));
byId("join-player").addEventListener("click", () => send("join", {role: "player", name: byId("player-name").value}, byId("join-player")));
byId("join-observer").addEventListener("click", () => send("join", {role: "observer"}, byId("join-observer")));
byId("start-match").addEventListener("click", () => send("start", {}, byId("start-match")));
byId("details-toggle").addEventListener("click", () => { const panel = byId("details-panel"); panel.hidden = !panel.hidden; byId("details-toggle").setAttribute("aria-expanded", String(!panel.hidden)); });
byId("modal-action").addEventListener("click", () => { hideModal(); setScreen("lobby"); });
connect();
return {state, handleMessage, move};
}
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, directionFromKey, directionFromSwipe, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, boot});
if (typeof module !== "undefined" && module.exports) module.exports = api;
root.Maze32Player = api;
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });
})(typeof globalThis !== "undefined" ? globalThis : this);