feat(web): render canonical living hedge maze walls

This commit is contained in:
2026-09-11 23:46:02 +03:00
parent 9ce44cdef7
commit 07eaed9c93
10 changed files with 480 additions and 32 deletions
+100 -19
View File
@@ -302,6 +302,48 @@
return snapshot && Array.isArray(snapshot.cells) ? snapshot.cells.map(cell => cell[0]) : [];
}
function hedgeEdgeKey(x, y, vertical) {
return `${vertical ? "v" : "h"}:${x}:${y}`;
}
function hedgeVariant(edge, count) {
const seed = ((edge.x + 1) * 1103515245 + (edge.y + 1) * 12345 + (edge.vertical ? 97 : 13)) >>> 0;
return seed % count + 1;
}
function projectedHedgeEdges(cells, width) {
if (!Array.isArray(cells) || !Number.isInteger(width) || width < 1) return [];
const present = new Set(cells.map(cell => cell[0]));
const edges = new Map();
const add = (x, y, vertical, opened) => {
const key = hedgeEdgeKey(x, y, vertical);
const existing = edges.get(key);
edges.set(key, {key, x, y, vertical, opened: !!opened || !!(existing && existing.opened)});
};
cells.forEach(cell => {
if (!Array.isArray(cell) || cell.length < 3 || !Number.isInteger(cell[0])) return;
const index = cell[0]; const passages = cell[1]; const opened = cell[2];
const x = index % width; const y = Math.floor(index / width);
const eastVisible = present.has(index + 1) && (index + 1) % width !== 0;
const southVisible = present.has(index + width);
if (!(passages & 1) || (opened & 1)) add(x, y, false, opened & 1);
if (!(passages & 8) || (opened & 8)) add(x, y, true, opened & 8);
if ((!eastVisible && !(passages & 2)) || (opened & 2)) add(x + 1, y, true, opened & 2);
if ((!southVisible && !(passages & 4)) || (opened & 4)) add(x, y + 1, false, opened & 4);
});
return Array.from(edges.values());
}
function hedgeKeyForMove(cellIndex, width, direction) {
if (!Number.isInteger(cellIndex) || !Number.isInteger(width) || width < 1) return null;
const x = cellIndex % width; const y = Math.floor(cellIndex / width);
if (direction === "north") return hedgeEdgeKey(x, y, false);
if (direction === "east") return hedgeEdgeKey(x + 1, y, true);
if (direction === "south") return hedgeEdgeKey(x, y + 1, false);
if (direction === "west") return hedgeEdgeKey(x, y, true);
return null;
}
function clampObserverZoom(value) {
return Math.min(6, Math.max(.25, value));
}
@@ -313,7 +355,7 @@
function boot(documentObject, windowObject) {
const byId = id => documentObject.getElementById(id);
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null}, moveDirections: new Map(), touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, startX: null, startY: null, direction: null, repeatTimer: null, repeatMs: null}, dpad: {pointerId: null, direction: null, repeatTimer: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null, breakthroughTimer: null}, hedge: {openedKeys: new Set()}, moveDirections: new Map(), touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, startX: null, startY: null, direction: null, repeatTimer: null, repeatMs: null}, dpad: {pointerId: null, direction: null, repeatTimer: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
state.moveFlow = new LatestIntentFlow((direction, element) => sendMoveNow(direction, element));
function showNotice(text, kind, priority, persistent, key) {
@@ -332,8 +374,11 @@
function clearEffects() {
windowObject.clearTimeout(state.effects.impactTimer);
windowObject.clearTimeout(state.effects.rateTimer);
windowObject.clearTimeout(state.effects.breakthroughTimer);
state.effects.impactTimer = null;
state.effects.rateTimer = null;
state.effects.breakthroughTimer = null;
state.hedge.openedKeys.clear();
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"));
@@ -353,7 +398,11 @@
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);
const hedgeKey = state.snapshot && hedgeKeyForMove(state.snapshot.self, state.snapshot.width, direction);
const hedge = hedgeKey && maze.querySelector(`[data-hedge-key="${hedgeKey}"]`);
const hedgeUse = hedge && hedge.querySelector("use");
if (hedgeUse && Number.isInteger(hits) && hits > 0) hedgeUse.setAttribute("href", `/hedges.svg#hedge-impact-${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}`); if (hedgeUse) hedgeUse.setAttribute("href", `/hedges.svg#${hedge.dataset.hedgeSymbol}`); }, 340);
}
function showRateLimit() {
@@ -470,6 +519,51 @@
return wrapper;
}
function hedgeSymbol(edge) {
return edge.opened ? `hedge-breach-${hedgeVariant(edge, 3)}` : `hedge-intact-${hedgeVariant(edge, 6)}`;
}
function renderHedgeEdges(maze, cells, width, minX, minY) {
const edges = projectedHedgeEdges(cells, width);
const openedKeys = new Set(edges.filter(edge => edge.opened).map(edge => edge.key));
const newOpenedKeys = new Set([...openedKeys].filter(key => !state.hedge.openedKeys.has(key)));
const layer = documentObject.createElement("span");
layer.className = "hedge-layer";
edges.forEach(edge => {
const wrapper = documentObject.createElement("span");
const symbol = hedgeSymbol(edge);
wrapper.className = `hedge-edge ${edge.vertical ? "hedge-vertical" : "hedge-horizontal"}`;
wrapper.dataset.hedgeKey = edge.key;
wrapper.dataset.hedgeSymbol = symbol;
wrapper.style.setProperty("--hedge-x", String(edge.x - minX));
wrapper.style.setProperty("--hedge-y", String(edge.y - minY));
const svg = documentObject.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 160 40");
svg.setAttribute("preserveAspectRatio", "none");
const use = documentObject.createElementNS("http://www.w3.org/2000/svg", "use");
use.setAttribute("href", `/hedges.svg#${symbol}`);
svg.appendChild(use);
wrapper.appendChild(svg);
layer.appendChild(wrapper);
});
maze.appendChild(layer);
state.hedge.openedKeys = openedKeys;
if (newOpenedKeys.size && !state.reducedMotion) {
newOpenedKeys.forEach(key => {
const edge = layer.querySelector(`[data-hedge-key="${key}"]`);
const use = edge && edge.querySelector("use");
if (use) use.setAttribute("href", "/hedges.svg#hedge-breakthrough");
});
windowObject.clearTimeout(state.effects.breakthroughTimer);
state.effects.breakthroughTimer = windowObject.setTimeout(() => {
layer.querySelectorAll(".hedge-edge").forEach(edge => {
const use = edge.querySelector("use");
if (use) use.setAttribute("href", `/hedges.svg#${edge.dataset.hedgeSymbol}`);
});
}, 340);
}
}
function addObject(cell, object) {
const [kind, owner, variant] = object;
let marker;
@@ -527,19 +621,12 @@
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);
maze.appendChild(cell);
});
renderHedgeEdges(maze, snapshot.cells, snapshot.width, minX, minY);
byId("map-placeholder").hidden = true;
}
@@ -601,6 +688,7 @@
setScreen("observer");
const maze = byId("observer-maze");
maze.replaceChildren();
maze.style.setProperty("--cell", `${OBSERVER_CELL_SIZE}px`);
maze.style.gridTemplateColumns = `repeat(${snapshot.width}, ${OBSERVER_CELL_SIZE}px)`;
maze.style.gridTemplateRows = `repeat(${snapshot.height}, ${OBSERVER_CELL_SIZE}px)`;
const objects = new Map();
@@ -612,14 +700,6 @@
cell.className = "cell";
cell.style.gridColumn = String(index % snapshot.width + 1);
cell.style.gridRow = String(Math.floor(index / snapshot.width) + 1);
if (!(passages & 1)) cell.classList.add("wall-n");
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) {
@@ -629,6 +709,7 @@
});
maze.appendChild(cell);
});
renderHedgeEdges(maze, snapshot.cells, snapshot.width, 0, 0);
players.forEach((player, index) => {
const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`;
byId(`observer-p${index + 1}`).textContent = !player.connected ? "нет связи" : player.exited ? "снаружи" : key;
@@ -935,7 +1016,7 @@
return {state, handleMessage, move, resetAvatarControl, latencySamples: () => state.latency.snapshot(), pointerFeedbackSamples: () => state.latency.pointerSnapshot()};
}
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_ADJECTIVES, NAME_NOUNS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, DIRECTIONAL_PAD_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, playerFocalPoint, playerFocalMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot});
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_ADJECTIVES, NAME_NOUNS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, DIRECTIONAL_PAD_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, playerFocalPoint, playerFocalMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, hedgeEdgeKey, hedgeKeyForMove, hedgeVariant, projectedHedgeEdges, clampObserverZoom, observerFitScale, boot});
if (typeof module !== "undefined" && module.exports) module.exports = api;
root.Maze32Player = api;
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });