feat(game): add persistent bush-wall breakthroughs
This commit is contained in:
+7
-6
@@ -219,7 +219,8 @@ conceptual records include:
|
|||||||
|
|
||||||
- `MazeConfig`: width, height, movement cooldown, object-density settings, and
|
- `MazeConfig`: width, height, movement cooldown, object-density settings, and
|
||||||
balance thresholds;
|
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,
|
- `PlayerState`: slot, display name, session token reference, position,
|
||||||
inventory, vision effect, connection state, and last accepted move time;
|
inventory, vision effect, connection state, and last accepted move time;
|
||||||
- `KeyState`: owner and either a ground position or carrier;
|
- `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
|
below that threshold. Responsive layout changes presentation only; they never
|
||||||
change projection content, command validation, or server authority.
|
change projection content, command validation, or server authority.
|
||||||
|
|
||||||
The browser may correlate a server rejection only with its matching outstanding
|
The ESP stores each player's bounded perseverance streak and a fixed-size
|
||||||
movement intention for presentation feedback. A `blocked` rejection shakes the
|
opened-passage overlay. It alone expires streaks at 3,000 ms, opens both passage
|
||||||
already visible projection in that attempted direction, while `rate_limited`
|
bits, and projects changed passages only in cells already visible to that role.
|
||||||
briefly darkens and blurs the maze only. Neither effect changes or derives game
|
The browser may correlate a matching server-approved impact stage with an
|
||||||
state, and both use bounded, resettable local timers.
|
already visible bush wall; it never derives or opens topology locally.
|
||||||
|
|
||||||
Player projections are translated only within their received cells so the
|
Player projections are translated only within their received cells so the
|
||||||
authoritative `self` cell aligns with the responsive local focal point. That
|
authoritative `self` cell aligns with the responsive local focal point. That
|
||||||
|
|||||||
@@ -109,8 +109,10 @@ approximately five minutes. There is no match time limit.
|
|||||||
simultaneous turns.
|
simultaneous turns.
|
||||||
- A small server-enforced minimum interval exists between accepted moves. Its
|
- A small server-enforced minimum interval exists between accepted moves. Its
|
||||||
value is configurable for playtesting.
|
value is configurable for playtesting.
|
||||||
- Rejected movement into a wall does not change position and is not a
|
- A server-accepted impact against the same closed interior wall from the same
|
||||||
successful move.
|
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.
|
- Players do not block each other and may occupy the same cell.
|
||||||
- A meeting occurs when both players 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.
|
- There are no attacks, health, damage, or other combat mechanics in MVP.
|
||||||
|
|||||||
@@ -2271,7 +2271,7 @@ The following planning-time observation motivated the milestone:
|
|||||||
|
|
||||||
## Milestone 022 — Persistent bush-wall breakthrough
|
## Milestone 022 — Persistent bush-wall breakthrough
|
||||||
|
|
||||||
**Status:** `NOT_STARTED`
|
**Status:** `COMPLETE`
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|
||||||
@@ -2426,3 +2426,149 @@ The following planning-time decisions define this milestone:
|
|||||||
always available to both players.
|
always available to both players.
|
||||||
- Implementation, automated verification, physical-device checks, and owner
|
- Implementation, automated verification, physical-device checks, and owner
|
||||||
acceptance are deferred until all preceding milestones are `COMPLETE`.
|
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.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ extern "C" {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#define MAZE32_GAME_PLAYER_COUNT 2U
|
#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_COMMAND_CAPACITY 16U
|
||||||
#define MAZE32_GAME_KEY_COUNT 2U
|
#define MAZE32_GAME_KEY_COUNT 2U
|
||||||
#define MAZE32_GAME_NO_KEY UINT8_MAX
|
#define MAZE32_GAME_NO_KEY UINT8_MAX
|
||||||
@@ -101,6 +103,10 @@ typedef struct {
|
|||||||
uint8_t exited;
|
uint8_t exited;
|
||||||
uint8_t connected;
|
uint8_t connected;
|
||||||
uint64_t reconnect_deadline_ms;
|
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;
|
} maze32_game_player_t;
|
||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -139,6 +145,8 @@ typedef struct {
|
|||||||
uint8_t activated_artifact;
|
uint8_t activated_artifact;
|
||||||
uint8_t opened_exit;
|
uint8_t opened_exit;
|
||||||
uint8_t outward_exit;
|
uint8_t outward_exit;
|
||||||
|
uint8_t breakthrough_hits;
|
||||||
|
uint8_t breakthrough_opened;
|
||||||
uint8_t finished;
|
uint8_t finished;
|
||||||
uint8_t key;
|
uint8_t key;
|
||||||
uint8_t artifact;
|
uint8_t artifact;
|
||||||
@@ -154,6 +162,7 @@ typedef struct {
|
|||||||
maze32_game_key_t keys[MAZE32_GAME_KEY_COUNT];
|
maze32_game_key_t keys[MAZE32_GAME_KEY_COUNT];
|
||||||
maze32_game_exit_t exits[MAZE32_GAME_PLAYER_COUNT];
|
maze32_game_exit_t exits[MAZE32_GAME_PLAYER_COUNT];
|
||||||
uint8_t consumed_artifacts[MAZE32_GAME_MAX_ARTIFACTS];
|
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];
|
maze32_game_command_t commands[MAZE32_GAME_COMMAND_CAPACITY];
|
||||||
uint8_t command_head;
|
uint8_t command_head;
|
||||||
uint8_t command_count;
|
uint8_t command_count;
|
||||||
@@ -179,6 +188,7 @@ typedef enum {
|
|||||||
typedef struct {
|
typedef struct {
|
||||||
uint16_t cell_index;
|
uint16_t cell_index;
|
||||||
uint8_t passages;
|
uint8_t passages;
|
||||||
|
uint8_t opened_passages;
|
||||||
} maze32_view_cell_t;
|
} maze32_view_cell_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ static maze32_passage_t direction_passage(maze32_game_direction_t direction) {
|
|||||||
return PASSAGES[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) {
|
static maze32_cell_t moved_cell(maze32_cell_t cell, maze32_game_direction_t direction) {
|
||||||
if (direction == MAZE32_GAME_DIRECTION_NORTH) {
|
if (direction == MAZE32_GAME_DIRECTION_NORTH) {
|
||||||
cell.y--;
|
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;
|
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) {
|
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 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;
|
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) {
|
void maze32_game_set_phase(maze32_game_t* game, maze32_game_phase_t phase) {
|
||||||
if (game != NULL && phase >= MAZE32_GAME_PHASE_COUNTDOWN &&
|
if (game != NULL && phase >= MAZE32_GAME_PHASE_COUNTDOWN &&
|
||||||
phase <= MAZE32_GAME_PHASE_FINISHED) {
|
phase <= MAZE32_GAME_PHASE_FINISHED) {
|
||||||
|
if (phase != MAZE32_GAME_PHASE_ACTIVE) {
|
||||||
|
reset_all_perseverance(game);
|
||||||
|
}
|
||||||
if (phase == MAZE32_GAME_PHASE_ACTIVE &&
|
if (phase == MAZE32_GAME_PHASE_ACTIVE &&
|
||||||
(!game->players[0].connected || !game->players[1].connected)) {
|
(!game->players[0].connected || !game->players[1].connected)) {
|
||||||
game->phase_before_pause = MAZE32_GAME_PHASE_ACTIVE;
|
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->paused_started_ms = 0U;
|
||||||
game->command_head = 0U;
|
game->command_head = 0U;
|
||||||
game->command_count = 0U;
|
game->command_count = 0U;
|
||||||
|
reset_all_perseverance(game);
|
||||||
game->state_sequence++;
|
game->state_sequence++;
|
||||||
return 1;
|
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->phase = MAZE32_GAME_PHASE_FINISHED;
|
||||||
game->final_result = game->first_exited_player == 0U ? MAZE32_GAME_FINAL_PLAYER_ONE
|
game->final_result = game->first_exited_player == 0U ? MAZE32_GAME_FINAL_PLAYER_ONE
|
||||||
: MAZE32_GAME_FINAL_PLAYER_TWO;
|
: MAZE32_GAME_FINAL_PLAYER_TWO;
|
||||||
|
reset_all_perseverance(game);
|
||||||
game->state_sequence++;
|
game->state_sequence++;
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -219,6 +274,7 @@ maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8
|
|||||||
}
|
}
|
||||||
|
|
||||||
player->connected = 0U;
|
player->connected = 0U;
|
||||||
|
reset_perseverance(player);
|
||||||
player->reconnect_deadline_ms = now_ms > UINT64_MAX - MAZE32_GAME_RECONNECT_TIMEOUT_MS
|
player->reconnect_deadline_ms = now_ms > UINT64_MAX - MAZE32_GAME_RECONNECT_TIMEOUT_MS
|
||||||
? UINT64_MAX
|
? UINT64_MAX
|
||||||
: now_ms + MAZE32_GAME_RECONNECT_TIMEOUT_MS;
|
: 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->paused_started_ms = now_ms;
|
||||||
game->command_head = 0U;
|
game->command_head = 0U;
|
||||||
game->command_count = 0U;
|
game->command_count = 0U;
|
||||||
|
reset_all_perseverance(game);
|
||||||
}
|
}
|
||||||
game->state_sequence++;
|
game->state_sequence++;
|
||||||
return MAZE32_GAME_RESULT_ACCEPTED;
|
return MAZE32_GAME_RESULT_ACCEPTED;
|
||||||
@@ -373,6 +430,7 @@ static void finish_outward_move(maze32_game_t* game, uint8_t player_index, uint6
|
|||||||
} else {
|
} else {
|
||||||
game->phase = MAZE32_GAME_PHASE_FINISHED;
|
game->phase = MAZE32_GAME_PHASE_FINISHED;
|
||||||
game->final_result = MAZE32_GAME_FINAL_DRAW;
|
game->final_result = MAZE32_GAME_FINAL_DRAW;
|
||||||
|
reset_all_perseverance(game);
|
||||||
processed->finished = 1U;
|
processed->finished = 1U;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -452,6 +510,7 @@ maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) {
|
|||||||
return processed;
|
return processed;
|
||||||
}
|
}
|
||||||
if (direction_leaves_map(game->maze, player->position, command.direction)) {
|
if (direction_leaves_map(game->maze, player->position, command.direction)) {
|
||||||
|
reset_perseverance(player);
|
||||||
if (!cells_equal(player->position, game->maze->exits[player_index]) ||
|
if (!cells_equal(player->position, game->maze->exits[player_index]) ||
|
||||||
!game->exits[player_index].open) {
|
!game->exits[player_index].open) {
|
||||||
processed.result = MAZE32_GAME_RESULT_BLOCKED;
|
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);
|
finish_outward_move(game, player_index, command.submitted_ms, &processed);
|
||||||
} else {
|
} else {
|
||||||
maze32_passage_t passage = direction_passage(command.direction);
|
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;
|
processed.result = MAZE32_GAME_RESULT_BLOCKED;
|
||||||
return processed;
|
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)) {
|
if (reveal_all || is_visible(center, cell, radius)) {
|
||||||
uint16_t index = cell_index(game->maze, cell);
|
uint16_t index = cell_index(game->maze, cell);
|
||||||
view->cells[view->cell_count].cell_index = index;
|
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++;
|
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) {
|
for (size_t index = first; index < end; ++index) {
|
||||||
const maze32_view_cell_t* cell = &view->cells[index];
|
const maze32_view_cell_t* cell = &view->cells[index];
|
||||||
if (!append_text(output, output_size, &offset, "%s[%u,%u]", index == first ? "" : ",",
|
if (!append_text(output, output_size, &offset, "%s[%u,%u,%u]",
|
||||||
cell->cell_index, cell->passages)) {
|
index == first ? "" : ",", cell->cell_index, cell->passages,
|
||||||
|
cell->opened_passages)) {
|
||||||
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
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,
|
static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_t* session,
|
||||||
const maze32_client_message_t* message) {
|
const maze32_client_message_t* message) {
|
||||||
if (session->role == MAZE32_ROLE_OBSERVER) {
|
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) {
|
if (finished_now) {
|
||||||
complete_match(gateway);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
char acknowledgement[160] = "";
|
char acknowledgement[160] = "";
|
||||||
|
|||||||
+6
-7
@@ -56,13 +56,12 @@ a maze boundary, and is recalculated on resize and rotation.
|
|||||||
|
|
||||||
## Feedback and message rail
|
## Feedback and message rail
|
||||||
|
|
||||||
The browser stores the direction for each outstanding move request. Only after
|
The browser stores the direction for each outstanding move request. A matching
|
||||||
a matching server `blocked` rejection does it shake the already rendered maze
|
server `blocked` rejection may include only that player's approved breakthrough
|
||||||
in the attempted direction for at most 340 ms; it shows no blocked-move text or spike
|
stage, which bends the already rendered bush wall for at most 340 ms without
|
||||||
notice. A matching `rate_limited` rejection darkens and blurs only the maze for
|
blocked-move text. Opened-passage bits in an ordinary cell projection render as
|
||||||
at most 520 ms. Each effect has one restartable timer and is cleared on pause,
|
a permanent trampled bush gap. Effects have one restartable timer and clear on
|
||||||
result, reset, disconnect, and control reset. Reduced-motion mode uses a static
|
pause, result, reset, disconnect, and control reset. Reduced motion is static.
|
||||||
maze border cue for blocked movement instead of a shake.
|
|
||||||
|
|
||||||
All remaining Russian system messages use one `role=status` live message rail
|
All remaining Russian system messages use one `role=status` live message rail
|
||||||
above the application top bar. One connection-loss message has highest priority
|
above the application top bar. One connection-loss message has highest priority
|
||||||
|
|||||||
+6
-6
@@ -127,12 +127,12 @@ are not MVP requirements.
|
|||||||
- Player zoom and pan controls are not part of the MVP.
|
- Player zoom and pan controls are not part of the MVP.
|
||||||
- Use different colors plus labels, outlines, shapes, or icons to distinguish
|
- Use different colors plus labels, outlines, shapes, or icons to distinguish
|
||||||
player ownership and object types.
|
player ownership and object types.
|
||||||
- A server `blocked` rejection shakes the visible maze opposite the attempted
|
- Server-approved breakthrough impacts one through four bend the visible bush
|
||||||
cardinal movement for at most 340 ms, without text or spike notice. A
|
wall with four increasing restrained stages and no text or spike notice. A
|
||||||
`rate_limited` rejection darkens and blurs only the maze for at most 520 ms,
|
fifth accepted move is reflected by a permanent textured trampled-bush gap.
|
||||||
also without text. Repeated feedback restarts one bounded effect of each type.
|
The browser uses one restartable impact timer and no generated particles.
|
||||||
- Reduced-motion mode replaces the blocked shake with one static directional
|
`rate_limited` still darkens and blurs only the maze for at most 520 ms.
|
||||||
border cue; rate-limit feedback remains immediate and non-animated.
|
- Reduced-motion mode uses static deformation and the same trampled-gap state.
|
||||||
|
|
||||||
## 8. Responsive behavior
|
## 8. Responsive behavior
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
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) {
|
int main(void) {
|
||||||
test_inventory_drop_and_sabotage();
|
test_inventory_drop_and_sabotage();
|
||||||
test_atomic_meeting_return();
|
test_atomic_meeting_return();
|
||||||
@@ -353,6 +384,7 @@ int main(void) {
|
|||||||
test_vision_artifact_duration();
|
test_vision_artifact_duration();
|
||||||
test_compass_and_role_projections();
|
test_compass_and_role_projections();
|
||||||
test_victory_and_draw_window();
|
test_victory_and_draw_window();
|
||||||
|
test_persistent_bush_wall_breakthrough();
|
||||||
puts("game mechanics tests: PASS");
|
puts("game mechanics tests: PASS");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, /\[data-reduced-motion="true"\] \.maze\.rate-limited \{ transition: none;/);
|
||||||
assert.match(css, /\.message-rail \{ position: fixed; top:/);
|
assert.match(css, /\.message-rail \{ position: fixed; top:/);
|
||||||
assert.match(script, /moveDirections: new Map\(\)/);
|
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.match(script, /if \(message\.payload\.code === "rate_limited"\) \{ showRateLimit\(\); feedback\(false\); return; \}/);
|
||||||
assert.doesNotMatch(script, /Там стена\. Очень убедительная\.|Слишком быстро! Сервер просит немного меньше турбо\./);
|
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", () => {
|
test("chunk assembly replaces projection cells without explored-map memory", () => {
|
||||||
const assembler = new player.SnapshotAssembler();
|
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 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]], objects: [["key", 0, 0, 10]]}};
|
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);
|
assert.equal(assembler.accept(second), null);
|
||||||
const complete = assembler.accept(first);
|
const complete = assembler.accept(first);
|
||||||
assert.deepEqual(player.projectedCellIndices(complete), [9, 10]);
|
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.deepEqual(player.projectedCellIndices(replacement), [18]);
|
||||||
assert.equal(replacement.cells.some(cell => cell[0] === 9 || cell[0] === 10), false);
|
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(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: 33, height: 32, cells: [], objects: []}), false);
|
||||||
assert.equal(player.snapshotIsValid({width: 32, height: 33, 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 html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||||
const observerSection = html.match(/<section class="screen observer-screen"[\s\S]*?<\/section>/)[0];
|
const observerSection = html.match(/<section class="screen observer-screen"[\s\S]*?<\/section>/)[0];
|
||||||
assert.doesNotMatch(observerSection, /data-direction|drop-key|start-match/);
|
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);
|
const snapshot = new player.SnapshotAssembler().accept(observer);
|
||||||
assert.equal(snapshot.role, "observer");
|
assert.equal(snapshot.role, "observer");
|
||||||
assert.deepEqual(snapshot.players.map(item => item.cell), [0, 3]);
|
assert.deepEqual(snapshot.players.map(item => item.cell), [0, 3]);
|
||||||
|
|||||||
@@ -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-s { border-bottom-color: var(--plant); }
|
||||||
.cell.wall-w { border-left-color: var(--plant); }
|
.cell.wall-w { border-left-color: var(--plant); }
|
||||||
.cell::after { content: ""; position: absolute; inset: 7px; background: #1c1b27; opacity: .55; }
|
.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, .pawn { position: absolute; z-index: 3; inset: 16%; display: grid; place-items: center; }
|
||||||
.object svg, .pawn svg { width: 100%; height: 100%; }
|
.object svg, .pawn svg { width: 100%; height: 100%; }
|
||||||
.object-key { color: var(--p1); }
|
.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 artifact { 50% { transform: scale(.75); box-shadow: 0 0 18px var(--warning); } }
|
||||||
@keyframes exit-open { 50% { outline: 3px solid var(--success); } }
|
@keyframes exit-open { 50% { outline: 3px solid var(--success); } }
|
||||||
@keyframes pawn-in { from { transform: scale(.6); } }
|
@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-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-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)); } }
|
@keyframes impact-south { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) + 10px)); } }
|
||||||
|
|||||||
+19
-6
@@ -258,7 +258,7 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const cellLimit = snapshot.width * snapshot.height;
|
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 {
|
class SnapshotAssembler {
|
||||||
@@ -336,9 +336,10 @@
|
|||||||
state.effects.rateTimer = null;
|
state.effects.rateTimer = null;
|
||||||
byId("maze").classList.remove("impact-north", "impact-east", "impact-south", "impact-west", "rate-limited");
|
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");
|
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;
|
if (!direction) return;
|
||||||
const maze = byId("maze");
|
const maze = byId("maze");
|
||||||
const bump = byId("wall-bump");
|
const bump = byId("wall-bump");
|
||||||
@@ -349,7 +350,10 @@
|
|||||||
bump.classList.remove("impact-north", "impact-east", "impact-south", "impact-west");
|
bump.classList.remove("impact-north", "impact-east", "impact-south", "impact-west");
|
||||||
bump.classList.add(`impact-${direction}`);
|
bump.classList.add(`impact-${direction}`);
|
||||||
bump.classList.add("show");
|
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() {
|
function showRateLimit() {
|
||||||
@@ -500,7 +504,7 @@
|
|||||||
byId("map-placeholder").hidden = false;
|
byId("map-placeholder").hidden = false;
|
||||||
return;
|
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 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 minY = Math.min(...coordinates.map(cell => cell.y)); const maxY = Math.max(...coordinates.map(cell => cell.y));
|
||||||
const columns = maxX - minX + 1;
|
const columns = maxX - minX + 1;
|
||||||
@@ -521,11 +525,16 @@
|
|||||||
coordinates.forEach(data => {
|
coordinates.forEach(data => {
|
||||||
const cell = documentObject.createElement("span");
|
const cell = documentObject.createElement("span");
|
||||||
cell.className = "cell";
|
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);
|
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 & 1)) cell.classList.add("wall-n");
|
||||||
if (!(data.passages & 2)) cell.classList.add("wall-e");
|
if (!(data.passages & 2)) cell.classList.add("wall-e");
|
||||||
if (!(data.passages & 4)) cell.classList.add("wall-s");
|
if (!(data.passages & 4)) cell.classList.add("wall-s");
|
||||||
if (!(data.passages & 8)) cell.classList.add("wall-w");
|
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));
|
(objects.get(data.index) || []).forEach(object => addObject(cell, object));
|
||||||
const slot = state.session ? state.session.slot : 1;
|
const slot = state.session ? state.session.slot : 1;
|
||||||
if (data.index === snapshot.opponent) addPawn(cell, slot === 1 ? 2 : 1, true);
|
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); });
|
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 : [];
|
const players = Array.isArray(snapshot.players) ? snapshot.players : [];
|
||||||
snapshot.cells.forEach(data => {
|
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");
|
const cell = documentObject.createElement("span");
|
||||||
cell.className = "cell";
|
cell.className = "cell";
|
||||||
cell.style.gridColumn = String(index % snapshot.width + 1);
|
cell.style.gridColumn = String(index % snapshot.width + 1);
|
||||||
@@ -607,6 +616,10 @@
|
|||||||
if (!(passages & 2)) cell.classList.add("wall-e");
|
if (!(passages & 2)) cell.classList.add("wall-e");
|
||||||
if (!(passages & 4)) cell.classList.add("wall-s");
|
if (!(passages & 4)) cell.classList.add("wall-s");
|
||||||
if (!(passages & 8)) cell.classList.add("wall-w");
|
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));
|
(objects.get(index) || []).forEach(object => addObject(cell, object));
|
||||||
players.forEach((player, playerIndex) => {
|
players.forEach((player, playerIndex) => {
|
||||||
if (player.cell === index && !player.exited) {
|
if (player.cell === index && !player.exited) {
|
||||||
@@ -708,7 +721,7 @@
|
|||||||
state.moveDirections.delete(message.requestId);
|
state.moveDirections.delete(message.requestId);
|
||||||
state.moveFlow.complete(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 === "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; }
|
if (message.payload.code === "rate_limited") { showRateLimit(); feedback(false); return; }
|
||||||
showNotice(localError(message.payload.code), "error"); feedback(false);
|
showNotice(localError(message.payload.code), "error"); feedback(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user