Files
netrunner-landing/matrix-engine/GlassShatter.tsx
T
2026-07-05 03:42:57 +07:00

351 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
};