351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
"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<GlassShatterProps> = ({
|
||
active = false,
|
||
}) => {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const activeRef = useRef(active);
|
||
const glRef = useRef<WebGL2RenderingContext | null>(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 (
|
||
<div
|
||
ref={containerRef}
|
||
className="absolute inset-0 overflow-hidden pointer-events-none"
|
||
></div>
|
||
);
|
||
};
|