feat(ui): implement responsive player browser experience
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
.pio/
|
.pio/
|
||||||
.vscode/
|
.vscode/
|
||||||
.obsidian/
|
.obsidian/
|
||||||
|
data/*.gz
|
||||||
|
|||||||
@@ -657,7 +657,7 @@ Complete the authoritative gameplay mechanics and terminal match outcomes.
|
|||||||
|
|
||||||
## Milestone 008 — Player browser experience
|
## Milestone 008 — Player browser experience
|
||||||
|
|
||||||
**Status:** `NOT_STARTED`
|
**Status:** `IN_PROGRESS`
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|
||||||
@@ -705,9 +705,57 @@ into the browser.
|
|||||||
|
|
||||||
### Evidence
|
### Evidence
|
||||||
|
|
||||||
Completion evidence is not yet available because this milestone has not
|
Implementation is complete, but the milestone remains `IN_PROGRESS` until the
|
||||||
started. Required evidence includes browser-test output, desktop/mobile visual
|
required physical-device and current-stable-browser checks can be run.
|
||||||
checks, refresh recovery results, and filesystem image size.
|
|
||||||
|
- `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
|
## Milestone 009 — Observer mode and disconnect recovery
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ typedef struct {
|
|||||||
uint32_t state_sequence;
|
uint32_t state_sequence;
|
||||||
uint32_t meeting_sequence;
|
uint32_t meeting_sequence;
|
||||||
maze32_game_final_result_t final_result;
|
maze32_game_final_result_t final_result;
|
||||||
|
uint16_t width;
|
||||||
|
uint16_t height;
|
||||||
uint16_t self_cell_index;
|
uint16_t self_cell_index;
|
||||||
uint16_t opponent_cell_index;
|
uint16_t opponent_cell_index;
|
||||||
uint16_t observer_player_cell_indices[MAZE32_GAME_PLAYER_COUNT];
|
uint16_t observer_player_cell_indices[MAZE32_GAME_PLAYER_COUNT];
|
||||||
|
|||||||
@@ -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->state_sequence = game->state_sequence;
|
||||||
view->meeting_sequence = game->meeting_sequence;
|
view->meeting_sequence = game->meeting_sequence;
|
||||||
view->final_result = game->final_result;
|
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->self_carried_key = MAZE32_GAME_NO_KEY;
|
||||||
view->opponent_carried_key = MAZE32_GAME_NO_KEY;
|
view->opponent_carried_key = MAZE32_GAME_NO_KEY;
|
||||||
view->opponent_cell_index = UINT16_MAX;
|
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;
|
size_t offset = 0U;
|
||||||
if (!append_text(output, output_size, &offset,
|
if (!append_text(output, output_size, &offset,
|
||||||
"{\"v\":1,\"type\":\"view\",\"seq\":%lu,\"payload\":{\"role\":\"%s\","
|
"{\"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",
|
"\"chunk\":%u,\"chunks\":%u",
|
||||||
(unsigned long)view->state_sequence, role_name(view->role),
|
(unsigned long)view->state_sequence, role_name(view->role),
|
||||||
phase_name(view->phase), final_result_name(view->final_result),
|
phase_name(view->phase), final_result_name(view->final_result), view->width,
|
||||||
(unsigned long)view->meeting_sequence, (unsigned int)chunk_index,
|
view->height, (unsigned long)view->meeting_sequence, (unsigned int)chunk_index,
|
||||||
(unsigned int)total_chunks)) {
|
(unsigned int)total_chunks)) {
|
||||||
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,22 +7,37 @@
|
|||||||
#define MAZE32_STATIC_CHUNK_SIZE 1024
|
#define MAZE32_STATIC_CHUNK_SIZE 1024
|
||||||
|
|
||||||
static const char* TAG = "maze32_web";
|
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) {
|
static void preserve_static_context(void* context) {
|
||||||
(void)context;
|
(void)context;
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t send_index(httpd_req_t* request) {
|
static esp_err_t send_static_asset(httpd_req_t* request) {
|
||||||
FILE* file = fopen(INDEX_PATH, "rb");
|
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) {
|
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,
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||||
"Приложение временно недоступно");
|
"Приложение временно недоступно");
|
||||||
return ESP_FAIL;
|
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, "Content-Encoding", "gzip");
|
||||||
httpd_resp_set_hdr(request, "Cache-Control", "no-cache");
|
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) {
|
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;
|
result = ESP_FAIL;
|
||||||
}
|
}
|
||||||
fclose(file);
|
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();
|
httpd_config_t configuration = HTTPD_DEFAULT_CONFIG();
|
||||||
configuration.max_uri_handlers = 9;
|
configuration.max_uri_handlers = 12;
|
||||||
configuration.max_open_sockets = 9;
|
configuration.max_open_sockets = 9;
|
||||||
configuration.lru_purge_enable = false;
|
configuration.lru_purge_enable = false;
|
||||||
configuration.recv_wait_timeout = 5;
|
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 = {
|
const httpd_uri_t root = {
|
||||||
.uri = "/",
|
.uri = "/",
|
||||||
.method = HTTP_GET,
|
.method = HTTP_GET,
|
||||||
.handler = send_index,
|
.handler = send_static_asset,
|
||||||
.user_ctx = NULL,
|
.user_ctx = (void*)&INDEX_ASSET,
|
||||||
};
|
};
|
||||||
const httpd_uri_t index = {
|
const httpd_uri_t index = {
|
||||||
.uri = "/index.html",
|
.uri = "/index.html",
|
||||||
.method = HTTP_GET,
|
.method = HTTP_GET,
|
||||||
.handler = send_index,
|
.handler = send_static_asset,
|
||||||
.user_ctx = NULL,
|
.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 = {
|
const httpd_uri_t health_uri = {
|
||||||
.uri = "/health",
|
.uri = "/health",
|
||||||
@@ -111,6 +144,15 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
|
|||||||
if (result == ESP_OK) {
|
if (result == ESP_OK) {
|
||||||
result = httpd_register_uri_handler(*server, &index);
|
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) {
|
if (result == ESP_OK) {
|
||||||
result = httpd_register_uri_handler(*server, &health_uri);
|
result = httpd_register_uri_handler(*server, &health_uri);
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
+2
-2
@@ -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,
|
The serialized view is split into bounded JSON records of at most 512 bytes,
|
||||||
matching the WebSocket gateway's message bound.
|
matching the WebSocket gateway's message bound.
|
||||||
Every record uses the architecture's versioned envelope and carries the
|
Every record uses the architecture's versioned envelope and carries the
|
||||||
authoritative state sequence, role, match phase, meeting sequence, chunk index,
|
authoritative state sequence, role, match phase, maze width and height,
|
||||||
and chunk count. Role status appears once in chunk zero instead of being
|
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
|
duplicated across the snapshot. Cell records contain only the cell index and
|
||||||
current passage flags. Object records contain kind, owner, variant, and cell
|
current passage flags. Object records contain kind, owner, variant, and cell
|
||||||
index. Player records omit the `opponent` member when that player is not
|
index. Player records omit the `opponent` member when that player is not
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -78,4 +78,8 @@ cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
|||||||
-o "$test_directory/test_game_mechanics"
|
-o "$test_directory/test_game_mechanics"
|
||||||
|
|
||||||
"$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"
|
python3 "$project_directory/scripts/verify_web_assets.py"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from generate_web_assets import generate_assets
|
|||||||
|
|
||||||
|
|
||||||
PROJECT_DIRECTORY = Path(__file__).resolve().parent.parent
|
PROJECT_DIRECTORY = Path(__file__).resolve().parent.parent
|
||||||
|
COMPRESSED_PAYLOAD_LIMIT = 1572864
|
||||||
|
|
||||||
|
|
||||||
def tree_digest(directory: Path) -> str:
|
def tree_digest(directory: Path) -> str:
|
||||||
@@ -41,17 +42,25 @@ def main() -> int:
|
|||||||
if first_digest != checked_in_digest:
|
if first_digest != checked_in_digest:
|
||||||
raise AssertionError("Generated web assets are stale; regenerate the data directory")
|
raise AssertionError("Generated web assets are stale; regenerate the data directory")
|
||||||
|
|
||||||
index_source = (source_directory / "index.html").read_bytes()
|
for compressed_path in sorted(path for path in first.rglob("*.gz") if path.is_file()):
|
||||||
index_compressed = (first / "index.html.gz").read_bytes()
|
relative_path = compressed_path.relative_to(first)
|
||||||
if gzip.decompress(index_compressed) != index_source:
|
source_relative = Path(str(relative_path)[:-3])
|
||||||
raise AssertionError("Compressed index does not reproduce its source")
|
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()
|
compressed_size = sum(path.stat().st_size for path in first.rglob("*.gz"))
|
||||||
provision_compressed = (first / "provision.html.gz").read_bytes()
|
if compressed_size > COMPRESSED_PAYLOAD_LIMIT:
|
||||||
if gzip.decompress(provision_compressed) != provision_source:
|
raise AssertionError(
|
||||||
raise AssertionError("Compressed provisioning page does not reproduce its source")
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ static void serialize_and_inspect(const maze32_view_t* view, int expect_hidden)
|
|||||||
assert(payload[written] == '\0');
|
assert(payload[written] == '\0');
|
||||||
assert(strstr(payload, "{\"v\":1,\"type\":\"view\",\"seq\":") == payload);
|
assert(strstr(payload, "{\"v\":1,\"type\":\"view\",\"seq\":") == payload);
|
||||||
assert(strstr(payload, "\"phase\":\"countdown\"") != NULL);
|
assert(strstr(payload, "\"phase\":\"countdown\"") != NULL);
|
||||||
|
assert(strstr(payload, "\"width\":8,\"height\":8") != NULL);
|
||||||
saw_last_cell |= strstr(payload, "[63,") != NULL;
|
saw_last_cell |= strstr(payload, "[63,") != NULL;
|
||||||
saw_hidden_key |= strstr(payload, "[\"key\",1,0,63]") != NULL;
|
saw_hidden_key |= strstr(payload, "[\"key\",1,0,63]") != NULL;
|
||||||
saw_players |= strstr(payload, "\"players\":[") != NULL;
|
saw_players |= strstr(payload, "\"players\":[") != NULL;
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
0.7.0
|
0.8.0
|
||||||
|
|||||||
@@ -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 |
+53
-117
@@ -2,129 +2,65 @@
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||||
<title>maze32 — лобби</title>
|
<meta name="theme-color" content="#090711">
|
||||||
<style>
|
<title>maze32 — побег из кустов</title>
|
||||||
: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; }
|
<link rel="stylesheet" href="/player.css">
|
||||||
* { 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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main id="app">
|
||||||
<header><h1>maze32</h1><span class="status" id="connection">Подключаем провода к воздуху…</span></header>
|
<header class="topbar">
|
||||||
<div class="grid">
|
<a class="brand" href="/" aria-label="maze32, на главную">MAZE<span>32</span></a>
|
||||||
<section>
|
<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>
|
<h2>Игроки</h2>
|
||||||
<div class="player p1" id="p1"><b>P1</b><span class="name">Свободно</span><span class="lamp">●</span></div>
|
<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 p2" id="p2"><b>P2</b><span class="name">Свободно</span><span class="lamp">●</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>
|
||||||
</section>
|
</article>
|
||||||
<section>
|
<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>
|
||||||
<h2>Наблюдатели</h2>
|
<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>
|
||||||
<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>
|
||||||
<div class="actions"><button id="start" disabled>Начать 3-2-1</button><a href="/provision"><button>Wi‑Fi</button></a></div>
|
|
||||||
</section>
|
</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>
|
||||||
<div id="notice" role="status">Сервер решает всё. Браузер только красиво моргает.</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>
|
</main>
|
||||||
<dialog id="overlay"><h2 id="overlay-title">Приготовились!</h2><div id="count">3</div><p id="overlay-text">Сервер синхронизирует старт.</p></dialog>
|
|
||||||
<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>
|
||||||
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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+220
@@ -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
@@ -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);
|
||||||
Reference in New Issue
Block a user