"use client"; import React, { useEffect, useRef } from "react"; import { createProgram } from "./gl-utils.js"; /** * Стеклянный оверлей поверх матрицы, показывается только когда `active` * (состояние "глаза закрыты" — см. isVpnOn в Hero.tsx). Бить можно кликом/тапом * в любом месте панели: каждый удар добавляет трещины, растущие из точки * удара; после нескольких ударов стекло осыпается и через паузу собирается * заново целым — эффект зациклен, стекло можно бить сколько угодно раз. * * Не связан с WASM-движком матрицы: работает на main thread (один * полноэкранный quad, без инстансинга — GPU-нагрузка пренебрежимо мала), * это отдельный DOM-канвас, наложенный поверх канваса матрицы обычным * порядком отрисовки браузера (в отличие от фонового слоя глубины, * встроенного внутрь matrix_worker.js — см. комментарий у fsBackground в * shaders.js: слою СВЕРХУ не нужно бороться с alpha:false соседнего канваса, * это ограничение работает только "сквозь" элемент, а не поверх него). */ const vs = `#version 300 es in vec2 a_position; out vec2 v_uv; void main() { v_uv = a_position * 0.5 + 0.5; gl_Position = vec4(a_position, 0.0, 1.0); }`.trim(); const fs = `#version 300 es precision highp float; in vec2 v_uv; out vec4 outColor; uniform float u_time; uniform vec2 u_resolution; uniform vec3 u_impacts[6]; // xy - точка удара (uv, y вверх), z - момент удара (сек), <=0 - слот пуст uniform float u_shardDisplace; // 0 = стекло целое, 1 = осколки полностью разлетелись/исчезли uniform float u_paneAlpha; // общая непрозрачность панели (fade-in при пересборке) // Стекло показывается ТОЛЬКО когда isVpnOn=true (глаза закрыты, см. Hero.tsx: // active={isVpnOn}), поэтому акцент всегда фиолетовый (та же палитра "vpn on", // что в fsBackground/атласе капель) — отдельный uniform под циан не нужен. const vec3 ACCENT = vec3(0.75, 0.1, 1.0); vec2 hash2(vec2 p) { p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3))); return fract(sin(p) * 43758.5453123); } float hash1(vec2 p) { return fract(sin(dot(p, vec2(41.3, 289.1))) * 43758.5453123); } void main() { vec2 uv = v_uv; vec2 aspect = vec2(u_resolution.x / max(u_resolution.y, 1.0), 1.0); // Фон панели до первого удара — тонкая горизонтальная скан-линия вместо // "стеклянного" диагонального блика: тот же приём, что CRT-скан-линии в // postProcessSource, а не типовой шейдерный клише-рефлекс стекла. float scan = sin(uv.y * u_resolution.y * 1.4 + u_time * 2.0) * 0.5 + 0.5; vec3 col = ACCENT * scan * 0.02; float alpha = scan * 0.02 + 0.01; // --- Ячейки Вороного = осколки стекла --- vec2 cellSize = vec2(0.085); vec2 gridUv = uv / cellSize; vec2 gridId = floor(gridUv); vec2 gridF = fract(gridUv); float minDist1 = 8.0, minDist2 = 8.0; vec2 cellSeed1 = vec2(0.0); for (int y = -1; y <= 1; y++) { for (int x = -1; x <= 1; x++) { vec2 neighbor = vec2(float(x), float(y)); vec2 cell = gridId + neighbor; vec2 point = neighbor + hash2(cell) - gridF; float d = dot(point, point); if (d < minDist1) { minDist2 = minDist1; minDist1 = d; cellSeed1 = cell; } else if (d < minDist2) { minDist2 = d; } } } float cellEdge = sqrt(minDist2) - sqrt(minDist1); // --- Влияние ударов: трещины растут кругом от каждой точки удара во времени --- float influence = 0.0; for (int i = 0; i < 6; i++) { vec3 imp = u_impacts[i]; if (imp.z <= 0.0) continue; float age = u_time - imp.z; if (age < 0.0) continue; // Трещина быстро "выстреливает" до локального радиуса и на нём // застывает — один удар даёт компактное пятно, а не весь экран; // полное покрытие достигается только накоплением НЕСКОЛЬКИХ ударов. float radius = 0.15 * (1.0 - exp(-age * 8.0)); float d = length((uv - imp.xy) * aspect); influence = max(influence, smoothstep(radius, radius - 0.06, d)); } // Резкая линия трещины + мягкое фиолетовое свечение вокруг неё (bloom), // тем же акцентным цветом — на прежних Вороных-гранях, но теперь в // основной палитре сайта и с лёгким неоновым ореолом вместо плоской линии. float crackLine = smoothstep(0.05, 0.0, cellEdge) * influence; float crackGlow = exp(-max(cellEdge, 0.0) / 0.05) * influence * 0.5; col += ACCENT * crackGlow; col = mix(col, ACCENT * 1.3, crackLine * 0.85); alpha = max(alpha, max(crackLine * 0.6, crackGlow * 0.35)); // Едва заметная "мозаика" осколков внутри уже треснувшей зоны. col += ACCENT * (hash1(cellSeed1) - 0.5) * 0.05 * influence; alpha += influence * 0.015; float crack = crackLine; // --- Разлёт осколков при полном разбитии --- if (u_shardDisplace > 0.001) { float fall = u_shardDisplace * u_shardDisplace; alpha *= max(0.0, 1.0 - fall); col += ACCENT * fall * 0.3 * step(0.001, crack + influence * 0.3); } outColor = vec4(col, clamp(alpha, 0.0, 1.0) * u_paneAlpha); }`.trim(); const MAX_IMPACTS = 6; const SHATTER_AFTER_HITS = 6; const BREAK_DURATION = 0.9; const REFORM_DURATION = 0.7; export interface GlassShatterProps { active?: boolean; } export const GlassShatter: React.FC = ({ active = false, }) => { const containerRef = useRef(null); const activeRef = useRef(active); const glRef = useRef(null); const startTimeRef = useRef(0); // Пул точек удара (как Shockwave-пул в lib.rs): t0 <= 0 значит слот свободен. const impactsRef = useRef( Array.from({ length: MAX_IMPACTS }, () => ({ x: 0, y: 0, t0: 0 })), ); const hitCountRef = useRef(0); const phaseRef = useRef<"idle" | "breaking" | "reforming">("idle"); const phaseStartRef = useRef(0); useEffect(() => { activeRef.current = active; if (active) { // Каждое новое появление стекла (переход в состояние "глаза закрыты") — // свежая целая панель, а не продолжение прошлого разбития. hitCountRef.current = 0; phaseRef.current = "idle"; impactsRef.current.forEach((imp) => (imp.t0 = 0)); } }, [active]); useEffect(() => { const container = containerRef.current; if (!container || typeof window === "undefined") return; const canvas = document.createElement("canvas"); Object.assign(canvas.style, { position: "absolute", inset: "0", width: "100%", height: "100%", display: "block", pointerEvents: "none", transition: "opacity 0.6s ease-in-out", opacity: "0", }); container.appendChild(canvas); const gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: false, antialias: false, }); if (!gl) return; glRef.current = gl; const program = createProgram(gl, vs, fs); if (!program) return; gl.useProgram(program); const quad = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, quad); gl.bufferData( gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW, ); const aPos = gl.getAttribLocation(program, "a_position"); gl.enableVertexAttribArray(aPos); gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); const uniforms = { time: gl.getUniformLocation(program, "u_time"), res: gl.getUniformLocation(program, "u_resolution"), impacts: gl.getUniformLocation(program, "u_impacts[0]"), shardDisplace: gl.getUniformLocation(program, "u_shardDisplace"), paneAlpha: gl.getUniformLocation(program, "u_paneAlpha"), }; const resize = () => { const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.max(1, container.clientWidth * dpr); canvas.height = Math.max(1, container.clientHeight * dpr); gl.viewport(0, 0, canvas.width, canvas.height); }; resize(); const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(container); startTimeRef.current = performance.now(); let visible = false; let rafId = 0; let paused = document.hidden; const draw = (now: number) => { rafId = requestAnimationFrame(draw); if (paused) return; const t = (now - startTimeRef.current) / 1000; const isActive = activeRef.current; if (isActive !== visible) { visible = isActive; canvas.style.opacity = visible ? "1" : "0"; } if (!isActive) return; const phase = phaseRef.current; let shardDisplace = 0; let paneAlpha = 1; if (phase === "breaking") { const dt = t - phaseStartRef.current; shardDisplace = Math.min(1, dt / BREAK_DURATION); if (shardDisplace >= 1) { phaseRef.current = "reforming"; phaseStartRef.current = t; impactsRef.current.forEach((imp) => (imp.t0 = 0)); hitCountRef.current = 0; } } else if (phase === "reforming") { const dt = t - phaseStartRef.current; paneAlpha = Math.min(1, dt / REFORM_DURATION); if (dt >= REFORM_DURATION) phaseRef.current = "idle"; } gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); gl.uniform1f(uniforms.time, t); gl.uniform2f(uniforms.res, canvas.width, canvas.height); gl.uniform1f(uniforms.shardDisplace, shardDisplace); gl.uniform1f(uniforms.paneAlpha, paneAlpha); const flat = new Float32Array(MAX_IMPACTS * 3); impactsRef.current.forEach((imp, i) => { flat[i * 3] = imp.x; flat[i * 3 + 1] = imp.y; flat[i * 3 + 2] = imp.t0; }); gl.uniform3fv(uniforms.impacts, flat); gl.drawArrays(gl.TRIANGLES, 0, 6); }; rafId = requestAnimationFrame(draw); const onVisibilityChange = () => { paused = document.hidden; }; document.addEventListener("visibilitychange", onVisibilityChange); return () => { document.removeEventListener("visibilitychange", onVisibilityChange); resizeObserver.disconnect(); cancelAnimationFrame(rafId); if (container.contains(canvas)) container.removeChild(canvas); glRef.current = null; }; }, []); // Клики/тапы по панели — окно, а не сам канвас (у канваса pointer-events: // none, чтобы не перехватывать переключатель VPN и кнопки, см. тот же // приём в NetrunnerMatrix.tsx). useEffect(() => { const container = containerRef.current; if (!container || typeof window === "undefined") return; const registerImpact = (clientX: number, clientY: number) => { if (!activeRef.current || phaseRef.current !== "idle") return; const rect = container.getBoundingClientRect(); const px = clientX - rect.left; const py = clientY - rect.top; if (px < 0 || py < 0 || px > rect.width || py > rect.height) return; const t = (performance.now() - startTimeRef.current) / 1000; const impacts = impactsRef.current; let slot = impacts.find((imp) => imp.t0 <= 0); if (!slot) { slot = impacts.reduce((oldest, imp) => imp.t0 < oldest.t0 ? imp : oldest, ); } slot.x = px / rect.width; slot.y = 1 - py / rect.height; slot.t0 = t; hitCountRef.current += 1; if (hitCountRef.current >= SHATTER_AFTER_HITS) { phaseRef.current = "breaking"; phaseStartRef.current = t; } }; const handlePointerDown = (e: PointerEvent | TouchEvent) => { if ( e.target instanceof Element && e.target.closest("button, a, input, textarea, label") ) return; const point = "touches" in e ? e.touches[0] : e; if (!point) return; registerImpact(point.clientX, point.clientY); }; const options = { passive: true }; window.addEventListener("pointerdown", handlePointerDown as EventListener, options); window.addEventListener("touchstart", handlePointerDown as EventListener, options); return () => { window.removeEventListener("pointerdown", handlePointerDown as EventListener); window.removeEventListener("touchstart", handlePointerDown as EventListener); }; }, []); return (
); };