From e10ad3acb8f8b737f2c79f34d3db5cac00b5ae2c Mon Sep 17 00:00:00 2001 From: sasa Date: Thu, 10 Sep 2026 23:12:42 +0300 Subject: [PATCH] feat(game): add persistent bush-wall breakthroughs --- ARCHITECTURE.md | 13 +- MVP.md | 6 +- PLANS.md | 148 ++++++++++++++++++- components/maze32_game/include/maze32_game.h | 10 ++ components/maze32_game/maze32_game.c | 89 ++++++++++- components/maze32_web/maze32_gateway.c | 21 ++- docs/PLAYER_UI.md | 13 +- docs/UI_STYLE.md | 12 +- test/host/test_game_mechanics.c | 32 ++++ test/web/test_player_app.js | 14 +- web/player.css | 10 ++ web/player.js | 25 +++- 12 files changed, 354 insertions(+), 39 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c2f8268..6645e84 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -219,7 +219,8 @@ conceptual records include: - `MazeConfig`: width, height, movement cooldown, object-density settings, and balance thresholds; -- `Maze`: compact cell topology plus landmark identifiers; +- `Maze`: compact generated cell topology plus landmark identifiers; +- `MatchTopology`: fixed-size server-opened passage overlay for one match; - `PlayerState`: slot, display name, session token reference, position, inventory, vision effect, connection state, and last accepted move time; - `KeyState`: owner and either a ground position or carrier; @@ -335,11 +336,11 @@ at least `1024 x 768` CSS pixels and presents a large-screen requirement message below that threshold. Responsive layout changes presentation only; they never change projection content, command validation, or server authority. -The browser may correlate a server rejection only with its matching outstanding -movement intention for presentation feedback. A `blocked` rejection shakes the -already visible projection in that attempted direction, while `rate_limited` -briefly darkens and blurs the maze only. Neither effect changes or derives game -state, and both use bounded, resettable local timers. +The ESP stores each player's bounded perseverance streak and a fixed-size +opened-passage overlay. It alone expires streaks at 3,000 ms, opens both passage +bits, and projects changed passages only in cells already visible to that role. +The browser may correlate a matching server-approved impact stage with an +already visible bush wall; it never derives or opens topology locally. Player projections are translated only within their received cells so the authoritative `self` cell aligns with the responsive local focal point. That diff --git a/MVP.md b/MVP.md index 0521e58..dbbd206 100644 --- a/MVP.md +++ b/MVP.md @@ -109,8 +109,10 @@ approximately five minutes. There is no match time limit. simultaneous turns. - A small server-enforced minimum interval exists between accepted moves. Its value is configurable for playtesting. -- Rejected movement into a wall does not change position and is not a - successful move. +- A server-accepted impact against the same closed interior wall from the same + cell and direction is tracked per player. The first four impacts do not move + the player; the fifth within three seconds of each previous impact opens the + passage in both cells and completes that move. Perimeter walls never open. - Players do not block each other and may occupy the same cell. - A meeting occurs when both players occupy the same cell. - There are no attacks, health, damage, or other combat mechanics in MVP. diff --git a/PLANS.md b/PLANS.md index c0f5824..707ecbc 100644 --- a/PLANS.md +++ b/PLANS.md @@ -2271,7 +2271,7 @@ The following planning-time observation motivated the milestone: ## Milestone 022 — Persistent bush-wall breakthrough -**Status:** `NOT_STARTED` +**Status:** `COMPLETE` ### Objective @@ -2426,3 +2426,149 @@ The following planning-time decisions define this milestone: always available to both players. - Implementation, automated verification, physical-device checks, and owner acceptance are deferred until all preceding milestones are `COMPLETE`. +- 2026-09-10 implementation evidence: `bash scripts/run_host_tests.sh` passed, + including `game persistent bush-wall breakthrough: PASS (five impacts, shared + passage)`. `node --test test/web/test_player_app.js` passed all 23 browser + tests and confirmed the bounded impact and trampled-passage presentation. + +## Milestone 023 — Top-down living hedge SVG sample library + +**Status:** `NOT_STARTED` + +### Objective + +Design and obtain project-owner approval for a lightweight SVG sample library +that makes maze walls read as varied living hedges viewed from above, with +recognizable leaves, twigs, depth, and matching damaged and permanently +breached variants for perseverance mode, without integrating the artwork into +the running game in this milestone. + +### Scope + +- Do not start this milestone until every preceding milestone is `COMPLETE`. +- Treat this as a visual exploration and asset-approval milestone only. Do not + replace the current maze renderer, change gameplay, modify the protocol, or + ship the samples in browser or firmware assets during this milestone. +- Before drawing, add a concise hedge-art contract to `docs/UI_STYLE.md` that + defines top-down perspective, scale, palette, outline weight, contrast, + organic variation, tiling rules, and the relationship between intact, + impacted, and permanently breached bushes. +- Create editable, hand-reviewable SVG source artwork with semantic groups and + stable symbol identifiers. Do not embed raster images, external fonts, + remote resources, scripts, generated metadata, or tool-specific binary data. +- Explore at least six visibly different intact straight-hedge samples. Vary + leaf clusters, branch paths, gaps, highlights, and silhouette rhythm while + keeping every sample unmistakably part of one visual family. +- Include representative joins and boundary situations needed by a cell-based + maze: continuous straight runs, perpendicular corners, crossings of adjacent + wall segments, exposed ends, and shared edges. Samples must rotate or mirror + cleanly for all four cardinal directions without directional lighting errors. +- Make individual leaves and small branch forks distinguishable at the actual + supported maze scales of 34, 40, and 46 CSS pixels per cell. Avoid both flat + green bars and noisy texture that turns into visual static on a phone. +- Use at least three vegetation tones plus restrained branch and shadow tones + to create depth. Maintain an identifiable hedge silhouette without relying + on green hue alone and keep players, keys, doors, passages, and interactive + feedback visually dominant. +- Draw a coherent perseverance sequence for the same representative wall: + intact, four increasing impact stages, the moment of breakthrough, and the + final permanent trampled opening. Branch bending, displaced leaves, broken + twigs, compressed foliage, and the cleared center must evolve consistently + rather than appearing as unrelated pictures. +- Provide at least three final breached-hedge samples with different organic + edge shapes. Each must clearly show a traversable gap from a top-down view, + retain torn or flattened bushes on both sides, and remain recognizable when + rotated or mirrored. +- Keep openings visually aligned to the authoritative cell passage and wide + enough for the player icon without implying a larger traversable area. The + samples must not encode coordinates, hidden passages, objects, ownership, or + other game state. +- Present all concepts together on one neutral comparison sheet and also as + isolated SVG symbols on representative dark maze cells. Include previews at + 1x and high-density mobile rendering scales, with no smoothing assumptions + that are unavailable in ordinary browsers. +- Record approximate raw and compressed byte costs for each candidate family + so later integration can select variety without threatening the 1.5 MiB web + asset budget or LittleFS partition. Prefer reusable leaf, twig, shadow, and + damage components over repeated paths where that improves size and clarity. +- Check color-vision robustness, grayscale silhouette, normal contrast, and + reduced-motion suitability. This milestone defines static frames only; + animation timing and runtime particle behavior remain governed by Milestone + 022 and are outside this milestone's implementation scope. +- End with an explicit project-owner selection of the preferred intact hedge + family, damage progression, breached variants, density, and palette. Runtime + integration requires a separately planned later milestone after approval. + +### Deliverables + +- Updated hedge-art and sample-review contract in `docs/UI_STYLE.md`. +- Editable SVG symbol sheet containing intact straight segments, joins, ends, + four impact stages, breakthrough frame, and permanent breached variants. +- Neutral comparison sheet plus representative dark-cell previews at 34, 40, + and 46 CSS pixel cell sizes and normal and high-density mobile scales. +- Brief asset manifest describing symbol identifiers, reuse rules, rotation and + mirroring constraints, accessibility checks, and raw and compressed sizes. +- Recorded project-owner selection and any requested revisions; no browser or + firmware integration changes. + +### Acceptance criteria + +1. The sample library contains at least six intact straight-hedge variants, + representative continuous runs, corners, shared-edge joins, and exposed + ends, all drawn as editable vector artwork with stable semantic identifiers. +2. Every intact sample resembles a living hedge viewed directly from above and + contains clearly distinguishable leaf clusters and branching structure. No + candidate is merely a solid, gradient, or textured green bar. +3. The candidates form one coherent visual family while showing enough + silhouette, foliage, branch, highlight, and gap variation to prevent obvious + repetition across a representative maze preview. +4. Leaves and primary branch forks remain recognizable at 34, 40, and 46 CSS + pixel cell sizes at 1x rendering. At high density they gain clarity without + changing geometry, producing seams, or becoming disproportionately detailed. +5. Straight segments, corners, joins, and ends connect without visible gaps, + doubled borders, accidental knots, or discontinuous lighting when rotated or + mirrored into every cardinal orientation. +6. At least three vegetation tones plus restrained branch and shadow tones + provide top-down depth. Grayscale and representative color-vision checks + preserve the hedge silhouette and do not reduce the legibility of players, + keys, doors, passages, or feedback. +7. One representative wall has a visually continuous set of intact, impact + stages one through four, breakthrough, and permanent-open frames. Each stage + shows plausible cumulative bending or damage and is distinguishable from + adjacent stages without explanatory text. +8. At least three permanent breached variants show an unambiguous traversable + center with torn, bent, or flattened vegetation on both sides. They remain + understandable from either side after rotation or mirroring and do not look + like an intact or merely shadowed wall. +9. Every breached opening aligns to one authoritative wall passage, is visually + wide enough for the existing player icon, and does not suggest access to an + adjacent closed cell or reveal any hidden maze state. +10. SVG files contain no raster payloads, external dependencies, scripts, + unnecessary editor metadata, or unbounded duplication. The manifest records + raw and compressed size for each candidate family and identifies reusable + components. +11. The comparison sheet shows all candidates on a neutral background and the + preferred candidates on representative dark maze cells at all target sizes. + Review does not require running or modifying the game. +12. No runtime HTML, CSS, JavaScript, firmware, protocol, generated web bundle, + or LittleFS image changes are part of this milestone. Automated checks + confirm SVG validity, local-only dependencies, stable identifiers, and + deterministic optimization output. +13. The project owner selects or explicitly rejects the intact hedge family, + impact sequence, breached variants, density, and palette after reviewing the + sample sheet at representative mobile scale. Any requested revisions are + recorded before the milestone can become `COMPLETE`. + +### Evidence + +The following planning-time request defines this milestone: + +- On 2026-09-10, the project owner requested a future sample-drawing milestone + because maze walls should look like varied living hedges from above rather + than simple green sticks. The owner specifically required visible leaves and + branches plus matching broken-bush samples for permanent breakthroughs in + perseverance mode, and requested planning only with no immediate artwork or + runtime implementation. +- Artwork creation, review previews, validation, owner approval, and any later + runtime integration are deferred. Milestone 022 remains the sole active + milestone at planning time. diff --git a/components/maze32_game/include/maze32_game.h b/components/maze32_game/include/maze32_game.h index f804692..f93badb 100644 --- a/components/maze32_game/include/maze32_game.h +++ b/components/maze32_game/include/maze32_game.h @@ -10,6 +10,8 @@ extern "C" { #endif #define MAZE32_GAME_PLAYER_COUNT 2U +#define MAZE32_GAME_BREAKTHROUGH_HITS 5U +#define MAZE32_GAME_BREAKTHROUGH_TIMEOUT_MS 3000U #define MAZE32_GAME_COMMAND_CAPACITY 16U #define MAZE32_GAME_KEY_COUNT 2U #define MAZE32_GAME_NO_KEY UINT8_MAX @@ -101,6 +103,10 @@ typedef struct { uint8_t exited; uint8_t connected; uint64_t reconnect_deadline_ms; + maze32_cell_t perseverance_cell; + maze32_game_direction_t perseverance_direction; + uint64_t perseverance_last_impact_ms; + uint8_t perseverance_hits; } maze32_game_player_t; typedef enum { @@ -139,6 +145,8 @@ typedef struct { uint8_t activated_artifact; uint8_t opened_exit; uint8_t outward_exit; + uint8_t breakthrough_hits; + uint8_t breakthrough_opened; uint8_t finished; uint8_t key; uint8_t artifact; @@ -154,6 +162,7 @@ typedef struct { maze32_game_key_t keys[MAZE32_GAME_KEY_COUNT]; maze32_game_exit_t exits[MAZE32_GAME_PLAYER_COUNT]; uint8_t consumed_artifacts[MAZE32_GAME_MAX_ARTIFACTS]; + uint8_t opened_passages[MAZE32_MAZE_MAX_CELLS]; maze32_game_command_t commands[MAZE32_GAME_COMMAND_CAPACITY]; uint8_t command_head; uint8_t command_count; @@ -179,6 +188,7 @@ typedef enum { typedef struct { uint16_t cell_index; uint8_t passages; + uint8_t opened_passages; } maze32_view_cell_t; typedef struct { diff --git a/components/maze32_game/maze32_game.c b/components/maze32_game/maze32_game.c index 7c37414..4390624 100644 --- a/components/maze32_game/maze32_game.c +++ b/components/maze32_game/maze32_game.c @@ -20,6 +20,19 @@ static maze32_passage_t direction_passage(maze32_game_direction_t direction) { return PASSAGES[direction]; } +static maze32_passage_t opposite_passage(maze32_passage_t passage) { + if (passage == MAZE32_PASSAGE_NORTH) { + return MAZE32_PASSAGE_SOUTH; + } + if (passage == MAZE32_PASSAGE_EAST) { + return MAZE32_PASSAGE_WEST; + } + if (passage == MAZE32_PASSAGE_SOUTH) { + return MAZE32_PASSAGE_NORTH; + } + return MAZE32_PASSAGE_EAST; +} + static maze32_cell_t moved_cell(maze32_cell_t cell, maze32_game_direction_t direction) { if (direction == MAZE32_GAME_DIRECTION_NORTH) { cell.y--; @@ -45,6 +58,43 @@ static int cells_equal(maze32_cell_t first, maze32_cell_t second) { return first.x == second.x && first.y == second.y; } +static uint8_t game_passages(const maze32_game_t* game, maze32_cell_t cell) { + return (uint8_t)(game->maze->passages[cell_index(game->maze, cell)] | + game->opened_passages[cell_index(game->maze, cell)]); +} + +static int game_has_passage(const maze32_game_t* game, maze32_cell_t cell, + maze32_passage_t passage) { + return (game_passages(game, cell) & passage) != 0U; +} + +static void reset_perseverance(maze32_game_player_t* player) { + player->perseverance_hits = 0U; + player->perseverance_last_impact_ms = 0U; +} + +static uint8_t record_wall_impact(maze32_game_player_t* player, maze32_cell_t cell, + maze32_game_direction_t direction, uint64_t submitted_ms) { + if (player->perseverance_hits == 0U || !cells_equal(player->perseverance_cell, cell) || + player->perseverance_direction != direction || + submitted_ms < player->perseverance_last_impact_ms || + submitted_ms - player->perseverance_last_impact_ms >= MAZE32_GAME_BREAKTHROUGH_TIMEOUT_MS) { + player->perseverance_cell = cell; + player->perseverance_direction = direction; + player->perseverance_hits = 1U; + } else { + player->perseverance_hits++; + } + player->perseverance_last_impact_ms = submitted_ms; + return player->perseverance_hits; +} + +static void reset_all_perseverance(maze32_game_t* game) { + for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) { + reset_perseverance(&game->players[player]); + } +} + static int is_visible(maze32_cell_t center, maze32_cell_t cell, uint8_t radius) { uint16_t dx = center.x > cell.x ? center.x - cell.x : cell.x - center.x; uint16_t dy = center.y > cell.y ? center.y - cell.y : cell.y - center.y; @@ -95,6 +145,9 @@ maze32_game_result_t maze32_game_init(maze32_game_t* game, const maze32_maze_t* void maze32_game_set_phase(maze32_game_t* game, maze32_game_phase_t phase) { if (game != NULL && phase >= MAZE32_GAME_PHASE_COUNTDOWN && phase <= MAZE32_GAME_PHASE_FINISHED) { + if (phase != MAZE32_GAME_PHASE_ACTIVE) { + reset_all_perseverance(game); + } if (phase == MAZE32_GAME_PHASE_ACTIVE && (!game->players[0].connected || !game->players[1].connected)) { game->phase_before_pause = MAZE32_GAME_PHASE_ACTIVE; @@ -159,6 +212,7 @@ int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) { game->paused_started_ms = 0U; game->command_head = 0U; game->command_count = 0U; + reset_all_perseverance(game); game->state_sequence++; return 1; } @@ -176,6 +230,7 @@ int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) { game->phase = MAZE32_GAME_PHASE_FINISHED; game->final_result = game->first_exited_player == 0U ? MAZE32_GAME_FINAL_PLAYER_ONE : MAZE32_GAME_FINAL_PLAYER_TWO; + reset_all_perseverance(game); game->state_sequence++; return 1; } @@ -219,6 +274,7 @@ maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8 } player->connected = 0U; + reset_perseverance(player); player->reconnect_deadline_ms = now_ms > UINT64_MAX - MAZE32_GAME_RECONNECT_TIMEOUT_MS ? UINT64_MAX : now_ms + MAZE32_GAME_RECONNECT_TIMEOUT_MS; @@ -229,6 +285,7 @@ maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8 game->paused_started_ms = now_ms; game->command_head = 0U; game->command_count = 0U; + reset_all_perseverance(game); } game->state_sequence++; return MAZE32_GAME_RESULT_ACCEPTED; @@ -373,6 +430,7 @@ static void finish_outward_move(maze32_game_t* game, uint8_t player_index, uint6 } else { game->phase = MAZE32_GAME_PHASE_FINISHED; game->final_result = MAZE32_GAME_FINAL_DRAW; + reset_all_perseverance(game); processed->finished = 1U; } } @@ -452,6 +510,7 @@ maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) { return processed; } if (direction_leaves_map(game->maze, player->position, command.direction)) { + reset_perseverance(player); if (!cells_equal(player->position, game->maze->exits[player_index]) || !game->exits[player_index].open) { processed.result = MAZE32_GAME_RESULT_BLOCKED; @@ -460,7 +519,27 @@ maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) { finish_outward_move(game, player_index, command.submitted_ms, &processed); } else { maze32_passage_t passage = direction_passage(command.direction); - if (!maze32_maze_has_passage(game->maze, player->position, passage)) { + if (!game_has_passage(game, player->position, passage)) { + processed.breakthrough_hits = record_wall_impact( + player, player->position, command.direction, command.submitted_ms); + if (processed.breakthrough_hits < MAZE32_GAME_BREAKTHROUGH_HITS) { + processed.result = MAZE32_GAME_RESULT_BLOCKED; + return processed; + } + maze32_cell_t destination = moved_cell(player->position, command.direction); + if (!valid_cell(game->maze, destination)) { + reset_perseverance(player); + processed.result = MAZE32_GAME_RESULT_BLOCKED; + return processed; + } + game->opened_passages[cell_index(game->maze, player->position)] |= passage; + game->opened_passages[cell_index(game->maze, destination)] |= opposite_passage(passage); + processed.breakthrough_opened = 1U; + reset_perseverance(player); + } + if (game_has_passage(game, player->position, passage)) { + reset_perseverance(player); + } else { processed.result = MAZE32_GAME_RESULT_BLOCKED; return processed; } @@ -589,7 +668,8 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_ if (reveal_all || is_visible(center, cell, radius)) { uint16_t index = cell_index(game->maze, cell); view->cells[view->cell_count].cell_index = index; - view->cells[view->cell_count].passages = game->maze->passages[index]; + view->cells[view->cell_count].passages = game_passages(game, cell); + view->cells[view->cell_count].opened_passages = game->opened_passages[index]; view->cell_count++; } } @@ -781,8 +861,9 @@ maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view, } for (size_t index = first; index < end; ++index) { const maze32_view_cell_t* cell = &view->cells[index]; - if (!append_text(output, output_size, &offset, "%s[%u,%u]", index == first ? "" : ",", - cell->cell_index, cell->passages)) { + if (!append_text(output, output_size, &offset, "%s[%u,%u,%u]", + index == first ? "" : ",", cell->cell_index, cell->passages, + cell->opened_passages)) { return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL; } } diff --git a/components/maze32_web/maze32_gateway.c b/components/maze32_web/maze32_gateway.c index 28bfaba..9b07eb4 100644 --- a/components/maze32_web/maze32_gateway.c +++ b/components/maze32_web/maze32_gateway.c @@ -722,6 +722,20 @@ static void send_game_error(maze32_gateway_t* gateway, int socket_fd, const char send_error(gateway, socket_fd, request_id, maze32_game_result_name(result), message); } +static void send_breakthrough_impact(maze32_gateway_t* gateway, int socket_fd, + const char* request_id, uint8_t hits) { + char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; + int written = snprintf(response, sizeof(response), + "{\"v\":1,\"type\":\"error\",\"seq\":%" PRIu32 + ",\"requestId\":\"%s\",\"payload\":{\"code\":\"blocked\"," + "\"message\":\"Там стена. Очень убедительная\"," + "\"breakthroughHits\":%u}}", + next_server_sequence(gateway), request_id, (unsigned int)hits); + if (written >= 0 && (size_t)written < sizeof(response)) { + enqueue(gateway, socket_fd, response); + } +} + static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_t* session, const maze32_client_message_t* message) { if (session->role == MAZE32_ROLE_OBSERVER) { @@ -753,7 +767,12 @@ static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_ if (finished_now) { complete_match(gateway); } - send_game_error(gateway, session->connection_id, message->request_id, result); + if (result == MAZE32_GAME_RESULT_BLOCKED && processed.breakthrough_hits > 0U) { + send_breakthrough_impact(gateway, session->connection_id, message->request_id, + processed.breakthrough_hits); + } else { + send_game_error(gateway, session->connection_id, message->request_id, result); + } return; } char acknowledgement[160] = ""; diff --git a/docs/PLAYER_UI.md b/docs/PLAYER_UI.md index bcf83d7..9bada48 100644 --- a/docs/PLAYER_UI.md +++ b/docs/PLAYER_UI.md @@ -56,13 +56,12 @@ a maze boundary, and is recalculated on resize and rotation. ## Feedback and message rail -The browser stores the direction for each outstanding move request. Only after -a matching server `blocked` rejection does it shake the already rendered maze -in the attempted direction for at most 340 ms; it shows no blocked-move text or spike -notice. A matching `rate_limited` rejection darkens and blurs only the maze for -at most 520 ms. Each effect has one restartable timer and is cleared on pause, -result, reset, disconnect, and control reset. Reduced-motion mode uses a static -maze border cue for blocked movement instead of a shake. +The browser stores the direction for each outstanding move request. A matching +server `blocked` rejection may include only that player's approved breakthrough +stage, which bends the already rendered bush wall for at most 340 ms without +blocked-move text. Opened-passage bits in an ordinary cell projection render as +a permanent trampled bush gap. Effects have one restartable timer and clear on +pause, result, reset, disconnect, and control reset. Reduced motion is static. All remaining Russian system messages use one `role=status` live message rail above the application top bar. One connection-loss message has highest priority diff --git a/docs/UI_STYLE.md b/docs/UI_STYLE.md index f40dc5b..671312f 100644 --- a/docs/UI_STYLE.md +++ b/docs/UI_STYLE.md @@ -127,12 +127,12 @@ are not MVP requirements. - Player zoom and pan controls are not part of the MVP. - Use different colors plus labels, outlines, shapes, or icons to distinguish player ownership and object types. -- A server `blocked` rejection shakes the visible maze opposite the attempted - cardinal movement for at most 340 ms, without text or spike notice. A - `rate_limited` rejection darkens and blurs only the maze for at most 520 ms, - also without text. Repeated feedback restarts one bounded effect of each type. -- Reduced-motion mode replaces the blocked shake with one static directional - border cue; rate-limit feedback remains immediate and non-animated. +- Server-approved breakthrough impacts one through four bend the visible bush + wall with four increasing restrained stages and no text or spike notice. A + fifth accepted move is reflected by a permanent textured trampled-bush gap. + The browser uses one restartable impact timer and no generated particles. + `rate_limited` still darkens and blurs only the maze for at most 520 ms. +- Reduced-motion mode uses static deformation and the same trampled-gap state. ## 8. Responsive behavior diff --git a/test/host/test_game_mechanics.c b/test/host/test_game_mechanics.c index 3577a1d..ce17062 100644 --- a/test/host/test_game_mechanics.c +++ b/test/host/test_game_mechanics.c @@ -346,6 +346,37 @@ static void test_victory_and_draw_window(void) { printf("game outward result window: PASS (200 ms draw boundary, 201 ms winner)\n"); } +static void test_persistent_bush_wall_breakthrough(void) { + maze32_maze_t maze; + open_test_grid(&maze); + maze.passages[index_of(2U, 2U)] &= (uint8_t)~MAZE32_PASSAGE_EAST; + maze.passages[index_of(3U, 2U)] &= (uint8_t)~MAZE32_PASSAGE_WEST; + maze32_game_t game = active_game(&maze); + game.players[0].position = (maze32_cell_t){2U, 2U}; + + for (uint8_t hit = 1U; hit < MAZE32_GAME_BREAKTHROUGH_HITS; ++hit) { + maze32_game_command_result_t result = + move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, hit * 500U); + assert(result.result == MAZE32_GAME_RESULT_BLOCKED); + assert(result.breakthrough_hits == hit); + assert(!result.breakthrough_opened); + assert(game.players[0].position.x == 2U); + } + maze32_game_command_result_t result = + move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 2500U); + assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.breakthrough_opened); + assert(result.breakthrough_hits == MAZE32_GAME_BREAKTHROUGH_HITS); + assert(game.players[0].position.x == 3U); + assert(game.opened_passages[index_of(2U, 2U)] & MAZE32_PASSAGE_EAST); + assert(game.opened_passages[index_of(3U, 2U)] & MAZE32_PASSAGE_WEST); + + game.players[1].position = (maze32_cell_t){3U, 2U}; + result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_WEST, 2600U); + assert(result.result == MAZE32_GAME_RESULT_ACCEPTED); + assert(game.players[1].position.x == 2U); + printf("game persistent bush-wall breakthrough: PASS (five impacts, shared passage)\n"); +} + int main(void) { test_inventory_drop_and_sabotage(); test_atomic_meeting_return(); @@ -353,6 +384,7 @@ int main(void) { test_vision_artifact_duration(); test_compass_and_role_projections(); test_victory_and_draw_window(); + test_persistent_bush_wall_breakthrough(); puts("game mechanics tests: PASS"); return 0; } diff --git a/test/web/test_player_app.js b/test/web/test_player_app.js index db4d066..b8dabac 100644 --- a/test/web/test_player_app.js +++ b/test/web/test_player_app.js @@ -165,7 +165,9 @@ test("playful feedback uses bounded maze-only effects and a top message rail", ( assert.match(css, /\[data-reduced-motion="true"\] \.maze\.rate-limited \{ transition: none;/); assert.match(css, /\.message-rail \{ position: fixed; top:/); assert.match(script, /moveDirections: new Map\(\)/); - assert.match(script, /if \(message\.payload\.code === "blocked"\) \{ showImpact\(direction\); feedback\(false\); return; \}/); + assert.match(script, /if \(message\.payload\.code === "blocked"\) \{ showImpact\(direction, message\.payload\.breakthroughHits\); feedback\(false\); return; \}/); + assert.match(css, /\.cell\.bush-impact/); + assert.match(css, /\.cell\.trampled-n::before/); assert.match(script, /if \(message\.payload\.code === "rate_limited"\) \{ showRateLimit\(\); feedback\(false\); return; \}/); assert.doesNotMatch(script, /Там стена\. Очень убедительная\.|Слишком быстро! Сервер просит немного меньше турбо\./); }); @@ -243,17 +245,17 @@ test("command envelopes contain intentions rather than client-owned state", () = 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]]}}; + const second = {v: 1, type: "view", seq: 20, payload: {role: "player1", width: 8, height: 8, chunk: 1, chunks: 2, cells: [[10, 15, 0]], 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, 2]], 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: []}}); + 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, 0]], 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: 32, height: 32, cells: [[1023, 15]], objects: []}), true); + assert.equal(player.snapshotIsValid({width: 32, height: 32, cells: [[1023, 15, 0]], objects: []}), true); assert.equal(player.snapshotIsValid({width: 33, height: 32, cells: [], objects: []}), false); assert.equal(player.snapshotIsValid({width: 32, height: 33, cells: [], objects: []}), false); }); @@ -344,7 +346,7 @@ test("observer protocol exposes no mutation control path", () => { const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8"); const observerSection = html.match(/
/)[0]; assert.doesNotMatch(observerSection, /data-direction|drop-key|start-match/); - const observer = {v: 1, type: "view", seq: 30, payload: {role: "observer", phase: "active", result: "none", width: 2, height: 2, players: [{cell: 0, key: null, exited: 0, connected: 1}, {cell: 3, key: 1, exited: 0, connected: 1}], chunk: 0, chunks: 1, cells: [[0, 6], [1, 12], [2, 3], [3, 9]], objects: []}}; + const observer = {v: 1, type: "view", seq: 30, payload: {role: "observer", phase: "active", result: "none", width: 2, height: 2, players: [{cell: 0, key: null, exited: 0, connected: 1}, {cell: 3, key: 1, exited: 0, connected: 1}], chunk: 0, chunks: 1, cells: [[0, 6, 0], [1, 12, 0], [2, 3, 0], [3, 9, 0]], objects: []}}; const snapshot = new player.SnapshotAssembler().accept(observer); assert.equal(snapshot.role, "observer"); assert.deepEqual(snapshot.players.map(item => item.cell), [0, 3]); diff --git a/web/player.css b/web/player.css index 7fcbb78..97f4978 100644 --- a/web/player.css +++ b/web/player.css @@ -118,6 +118,15 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; } .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; } +.cell.trampled-n::before, .cell.trampled-e::before, .cell.trampled-s::before, .cell.trampled-w::before { content: ""; position: absolute; z-index: 2; background: repeating-linear-gradient(90deg, #254d2d 0 4px, #6f8d43 4px 7px); opacity: .9; } +.cell.trampled-n::before { left: 4px; right: 4px; top: -3px; height: 6px; } +.cell.trampled-e::before { top: 4px; right: -3px; bottom: 4px; width: 6px; } +.cell.trampled-s::before { right: 4px; bottom: -3px; left: 4px; height: 6px; } +.cell.trampled-w::before { top: 4px; bottom: 4px; left: -3px; width: 6px; } +.cell.bush-impact { z-index: 5; animation: bush-resist .34s steps(3, end); } +.cell.bush-stage-2 { filter: saturate(1.25); } +.cell.bush-stage-3 { filter: brightness(1.2) saturate(1.4); } +.cell.bush-stage-4 { filter: brightness(1.35) saturate(1.65); } .object, .pawn { position: absolute; z-index: 3; inset: 16%; display: grid; place-items: center; } .object svg, .pawn svg { width: 100%; height: 100%; } .object-key { color: var(--p1); } @@ -306,6 +315,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; } @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 bush-resist { 50% { transform: translateX(3px) rotate(1deg); } } @keyframes impact-north { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) - 10px)); } } @keyframes impact-east { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px) + 10px), calc(-50% + var(--map-offset-y, 0px))); } } @keyframes impact-south { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) + 10px)); } } diff --git a/web/player.js b/web/player.js index c1fa171..1654395 100644 --- a/web/player.js +++ b/web/player.js @@ -258,7 +258,7 @@ 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); + return snapshot.cells.every(cell => Array.isArray(cell) && cell.length === 3 && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 15 && Number.isInteger(cell[2]) && cell[2] >= 0 && cell[2] <= 15) && snapshot.objects.every(object => Array.isArray(object) && object.length === 4 && Number.isInteger(object[3]) && object[3] >= 0 && object[3] < cellLimit); } class SnapshotAssembler { @@ -336,9 +336,10 @@ state.effects.rateTimer = null; byId("maze").classList.remove("impact-north", "impact-east", "impact-south", "impact-west", "rate-limited"); byId("wall-bump").classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west"); + documentObject.querySelectorAll(".cell.bush-impact").forEach(cell => cell.classList.remove("bush-impact", "bush-stage-1", "bush-stage-2", "bush-stage-3", "bush-stage-4")); } - function showImpact(direction) { + function showImpact(direction, hits) { if (!direction) return; const maze = byId("maze"); const bump = byId("wall-bump"); @@ -349,7 +350,10 @@ bump.classList.remove("impact-north", "impact-east", "impact-south", "impact-west"); bump.classList.add(`impact-${direction}`); bump.classList.add("show"); - state.effects.impactTimer = windowObject.setTimeout(() => { maze.classList.remove("impact-north", "impact-east", "impact-south", "impact-west"); bump.classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west"); }, 340); + const stage = Math.min(4, Math.max(1, Number.isInteger(hits) ? hits : 1)); + const target = state.snapshot && documentObject.querySelector(`.cell[data-cell-index="${state.snapshot.self}"]`); + if (target && Number.isInteger(hits) && hits > 0) target.classList.add("bush-impact", `bush-stage-${stage}`); + state.effects.impactTimer = windowObject.setTimeout(() => { maze.classList.remove("impact-north", "impact-east", "impact-south", "impact-west"); bump.classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west"); if (target) target.classList.remove("bush-impact", `bush-stage-${stage}`); }, 340); } function showRateLimit() { @@ -500,7 +504,7 @@ 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 coordinates = snapshot.cells.map(cell => ({index: cell[0], x: cell[0] % snapshot.width, y: Math.floor(cell[0] / snapshot.width), passages: cell[1], opened: cell[2]})); 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)); const columns = maxX - minX + 1; @@ -521,11 +525,16 @@ coordinates.forEach(data => { const cell = documentObject.createElement("span"); cell.className = "cell"; + cell.dataset.cellIndex = String(data.index); 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"); + if (data.opened & 1) cell.classList.add("trampled-n"); + if (data.opened & 2) cell.classList.add("trampled-e"); + if (data.opened & 4) cell.classList.add("trampled-s"); + if (data.opened & 8) cell.classList.add("trampled-w"); (objects.get(data.index) || []).forEach(object => addObject(cell, object)); const slot = state.session ? state.session.slot : 1; if (data.index === snapshot.opponent) addPawn(cell, slot === 1 ? 2 : 1, true); @@ -598,7 +607,7 @@ snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); }); const players = Array.isArray(snapshot.players) ? snapshot.players : []; snapshot.cells.forEach(data => { - const index = data[0]; const passages = data[1]; + const index = data[0]; const passages = data[1]; const opened = data[2]; const cell = documentObject.createElement("span"); cell.className = "cell"; cell.style.gridColumn = String(index % snapshot.width + 1); @@ -607,6 +616,10 @@ if (!(passages & 2)) cell.classList.add("wall-e"); if (!(passages & 4)) cell.classList.add("wall-s"); if (!(passages & 8)) cell.classList.add("wall-w"); + if (opened & 1) cell.classList.add("trampled-n"); + if (opened & 2) cell.classList.add("trampled-e"); + if (opened & 4) cell.classList.add("trampled-s"); + if (opened & 8) cell.classList.add("trampled-w"); (objects.get(index) || []).forEach(object => addObject(cell, object)); players.forEach((player, playerIndex) => { if (player.cell === index && !player.exited) { @@ -708,7 +721,7 @@ state.moveDirections.delete(message.requestId); state.moveFlow.complete(message.requestId); if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); } - if (message.payload.code === "blocked") { showImpact(direction); feedback(false); return; } + if (message.payload.code === "blocked") { showImpact(direction, message.payload.breakthroughHits); feedback(false); return; } if (message.payload.code === "rate_limited") { showRateLimit(); feedback(false); return; } showNotice(localError(message.payload.code), "error"); feedback(false); }