320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import React, { useRef, useEffect, useState, useCallback } from "react";
|
||
|
||
// Версия движка, подставляется query-параметром при загрузке воркера и WASM
|
||
// модуля (?v=...) — простой cache-busting при выкладке новой сборки
|
||
// matrix-engine, чтобы браузер/CDN не отдавали закэшированный старый файл.
|
||
const ENGINE_VERSION = "3.0.4";
|
||
|
||
export interface NetrunnerMatrixProps {
|
||
isSecure?: boolean;
|
||
assetsPath?: string;
|
||
isDarkMode?: boolean;
|
||
showEyes?: boolean;
|
||
}
|
||
|
||
/**
|
||
* React-обёртка над Matrix-движком. Сама ничего не рисует и не считает —
|
||
* только создаёт <canvas>, один раз передаёт владение им в Web Worker через
|
||
* transferControlToOffscreen() и дальше общается с воркером message-протоколом
|
||
* (см. типы сообщений ниже). Вся физика (WASM) и рендер (WebGL2) живут в
|
||
* воркере, чтобы тяжёлый цикл tick()+draw() не блокировал main thread и не
|
||
* подвешивал остальной интерфейс страницы.
|
||
*
|
||
* Message-протокол Main thread -> Worker:
|
||
* - INIT — единственный раз при монтировании: передаёт OffscreenCanvas
|
||
* и стартовые параметры (размеры, DPR, тема, VPN-статус, mobile).
|
||
* - PAUSE/RESUME — по visibilitychange, чтобы не рендерить и не тратить батарею
|
||
* во вкладке в фоне.
|
||
* - RESIZE — по ResizeObserver контейнера, пересчитывает канвас и пересоздаёт капли.
|
||
* - THEME — смена VPN-статуса и/или тёмной темы (перекрашивает атлас символов).
|
||
* - TILT — deviceorientation.gamma (наклон телефона) для лёгкого 3D-параллакса.
|
||
* - SHOCKWAVE/DRAW_START/DRAW_END/KINETIC_SWIPE/MOUSE_MOVE — интеракции указателя/тача.
|
||
*
|
||
* Worker -> Main thread:
|
||
* - READY — WASM-модуль загружен и первый кадр посчитан, можно проявлять канвас (fade-in).
|
||
*/
|
||
|
||
export const NetrunnerMatrix: React.FC<NetrunnerMatrixProps> = ({
|
||
isSecure = false,
|
||
assetsPath = "/wasm-matrix",
|
||
isDarkMode = true,
|
||
showEyes = true,
|
||
}) => {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const workerRef = useRef<Worker | null>(null);
|
||
// canvas.transferControlToOffscreen() можно вызвать РОВНО ОДИН РАЗ за всё
|
||
// время жизни конкретного <canvas> DOM-узла — повторный вызов бросает
|
||
// исключение. Этот флаг защищает эффект инициализации от повторного запуска
|
||
// (например, из-за React StrictMode двойного вызова эффектов в dev-режиме
|
||
// или гонки при быстром ре-монтировании).
|
||
const hasTransferred = useRef(false);
|
||
const [mounted, setMounted] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
return () => setMounted(false);
|
||
}, []);
|
||
|
||
const checkIsMobile = useCallback(() => {
|
||
if (typeof window === "undefined") return false;
|
||
// Убираем проверку на тач, оставляем только ширину
|
||
return window.innerWidth <= 768;
|
||
}, []);
|
||
|
||
const getEyeY = useCallback(() => {
|
||
const anchor = document.getElementById("eye-anchor");
|
||
const container = containerRef.current;
|
||
if (anchor && container) {
|
||
const ar = anchor.getBoundingClientRect();
|
||
const cr = container.getBoundingClientRect();
|
||
return ar.top - cr.top + ar.height / 2;
|
||
}
|
||
return typeof window !== "undefined" ? window.innerHeight * 0.4 : 300;
|
||
}, []);
|
||
|
||
// === 1. ИНИЦИАЛИЗАЦИЯ И УПРАВЛЕНИЕ ВОРКЕРОМ ===
|
||
// Создаёт canvas, передаёт его в Worker сообщением INIT, подписывается на
|
||
// visibilitychange (PAUSE/RESUME) и ResizeObserver (RESIZE). Выполняется
|
||
// один раз при монтировании (условия внутри — раннние return, а не смена
|
||
// зависимостей эффекта на каждый рендер).
|
||
useEffect(() => {
|
||
if (
|
||
!mounted ||
|
||
typeof window === "undefined" ||
|
||
!("OffscreenCanvas" in window)
|
||
)
|
||
return;
|
||
|
||
const container = containerRef.current;
|
||
if (!container || hasTransferred.current) return;
|
||
|
||
const canvas = document.createElement("canvas");
|
||
Object.assign(canvas.style, {
|
||
position: "absolute",
|
||
top: "0",
|
||
left: "0",
|
||
width: "100%",
|
||
height: "100%",
|
||
display: "block",
|
||
zIndex: "0",
|
||
pointerEvents: "none",
|
||
transition: "opacity 1.5s ease-in-out",
|
||
opacity: "0",
|
||
});
|
||
|
||
canvas.id = "matrix-canvas";
|
||
container.insertBefore(canvas, container.firstChild);
|
||
|
||
try {
|
||
const isMobile = checkIsMobile();
|
||
const isLowQuality =
|
||
isMobile || (navigator.hardwareConcurrency || 4) <= 4;
|
||
const currentDPR = window.devicePixelRatio || 1;
|
||
const optimalScale = isMobile ? Math.min(currentDPR, 1.25) : currentDPR;
|
||
const logicalWidth = container.clientWidth;
|
||
const logicalHeight = container.clientHeight;
|
||
|
||
const workerUrl = new URL(
|
||
`${assetsPath}/matrix_worker.js?v=${ENGINE_VERSION}`,
|
||
window.location.origin,
|
||
).href;
|
||
|
||
const worker = new Worker(workerUrl, { type: "module" });
|
||
workerRef.current = worker;
|
||
|
||
const offscreen = canvas.transferControlToOffscreen();
|
||
hasTransferred.current = true;
|
||
|
||
worker.postMessage(
|
||
{
|
||
type: "INIT",
|
||
payload: {
|
||
canvas: offscreen,
|
||
assetsPath,
|
||
logicalWidth,
|
||
logicalHeight,
|
||
pixelScale: optimalScale,
|
||
eyeY: getEyeY(),
|
||
isMobile,
|
||
isVpnOn: isSecure,
|
||
isDarkMode,
|
||
fontSize: isMobile ? 12 : 16,
|
||
isLowQuality,
|
||
showEyes,
|
||
},
|
||
},
|
||
[offscreen],
|
||
);
|
||
|
||
worker.onmessage = (e) => {
|
||
if (e.data.type === "READY") {
|
||
requestAnimationFrame(() => {
|
||
canvas.style.opacity = "1";
|
||
});
|
||
}
|
||
};
|
||
|
||
const onVisibilityChange = () => {
|
||
if (!workerRef.current) return;
|
||
if (document.hidden) {
|
||
workerRef.current.postMessage({ type: "PAUSE" });
|
||
} else {
|
||
workerRef.current.postMessage({ type: "RESUME" });
|
||
}
|
||
};
|
||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||
|
||
const resizeObserver = new ResizeObserver((entries) => {
|
||
for (let entry of entries) {
|
||
const { width: newWidth, height: newHeight } = entry.contentRect;
|
||
if (newWidth === 0 || newHeight === 0) continue;
|
||
|
||
// ИСПРАВЛЕНО: Вычисляем DPR и масштаб ВНУТРИ ResizeObserver
|
||
if (typeof window === "undefined") continue;
|
||
const currentDPR = window.devicePixelRatio || 1;
|
||
const isMobileNow = newWidth <= 768;
|
||
const optimalScale = isMobileNow
|
||
? Math.min(currentDPR, 1.25)
|
||
: currentDPR;
|
||
|
||
workerRef.current?.postMessage({
|
||
type: "RESIZE",
|
||
payload: {
|
||
logicalWidth: newWidth,
|
||
logicalHeight: newHeight,
|
||
pixelScale: optimalScale, // Передаем СВЕЖЕЕ значение!
|
||
assetsPath,
|
||
eyeY: getEyeY(),
|
||
isMobile: isMobileNow,
|
||
fontSize: isMobileNow ? 12 : 16,
|
||
isLowQuality: isMobileNow || isLowQuality,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
resizeObserver.observe(container);
|
||
|
||
return () => {
|
||
resizeObserver.disconnect();
|
||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||
if (workerRef.current) {
|
||
workerRef.current.terminate();
|
||
workerRef.current = null;
|
||
}
|
||
if (container.contains(canvas)) container.removeChild(canvas);
|
||
hasTransferred.current = false;
|
||
};
|
||
} catch (e) {
|
||
console.error("Matrix WebGL Bridge Failed:", e);
|
||
}
|
||
}, [mounted, checkIsMobile, getEyeY, assetsPath, showEyes, isDarkMode]);
|
||
|
||
// === 2. СИНХРОНИЗАЦИЯ ТЕМЫ И VPN ===
|
||
// Отдельный эффект от инициализации: isSecure/isDarkMode меняются по ходу
|
||
// жизни страницы (тумблер VPN, переключение темы), и каждый раз нужно
|
||
// только отправить THEME в уже живой воркер, не пересоздавая canvas/worker.
|
||
useEffect(() => {
|
||
if (workerRef.current && mounted) {
|
||
workerRef.current.postMessage({
|
||
type: "THEME",
|
||
payload: {
|
||
isVpnOn: isSecure,
|
||
isDarkMode: isDarkMode,
|
||
},
|
||
});
|
||
}
|
||
}, [isSecure, isDarkMode, mounted]);
|
||
|
||
// === 3. ИНТЕРАКТИВНОСТЬ ===
|
||
// Глобальные слушатели указателя/тача/гироскопа на window (не на canvas —
|
||
// у канваса pointerEvents: none, чтобы не перехватывать клики по UI поверх
|
||
// фона), с фильтром по closest("button, a, ...") чтобы не триггерить
|
||
// шоквейвы при обычных кликах по интерфейсу.
|
||
useEffect(() => {
|
||
if (!mounted || !workerRef.current || !containerRef.current) return;
|
||
|
||
const handleOrientation = (e: DeviceOrientationEvent) => {
|
||
let gamma = e.gamma || 0;
|
||
if (gamma > 45) gamma = 45;
|
||
if (gamma < -45) gamma = -45;
|
||
workerRef.current?.postMessage({
|
||
type: "TILT",
|
||
payload: { x: gamma / 45.0, y: 0 },
|
||
});
|
||
};
|
||
|
||
window.addEventListener("deviceorientation", handleOrientation);
|
||
|
||
const handleEvents = (e: any) => {
|
||
if (
|
||
e.target instanceof Element &&
|
||
e.target.closest("button, a, input, textarea, label")
|
||
)
|
||
return;
|
||
|
||
const isDown = e.type === "pointerdown" || e.type === "touchstart";
|
||
const isMove = e.type === "pointermove" || e.type === "touchmove";
|
||
|
||
const clientX = e.touches ? e.touches[0]?.clientX : e.clientX;
|
||
const clientY = e.touches ? e.touches[0]?.clientY : e.clientY;
|
||
|
||
if (clientX === undefined || clientY === undefined) return;
|
||
|
||
const rect = containerRef.current!.getBoundingClientRect();
|
||
const px = clientX - rect.left;
|
||
const py = clientY - rect.top;
|
||
|
||
if (isDown) {
|
||
workerRef.current?.postMessage({
|
||
type: "SHOCKWAVE",
|
||
payload: { x: px, y: py },
|
||
});
|
||
workerRef.current?.postMessage({
|
||
type: "DRAW_START",
|
||
payload: { x: px, y: py },
|
||
});
|
||
}
|
||
if (isMove) {
|
||
workerRef.current?.postMessage({
|
||
type: "KINETIC_SWIPE",
|
||
payload: { x: px, y: py },
|
||
});
|
||
workerRef.current?.postMessage({
|
||
type: "MOUSE_MOVE",
|
||
payload: { x: px, y: py },
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleStop = () =>
|
||
workerRef.current?.postMessage({ type: "DRAW_END", payload: {} });
|
||
|
||
const options = { passive: true };
|
||
window.addEventListener("pointerdown", handleEvents, options);
|
||
window.addEventListener("pointermove", handleEvents, options);
|
||
window.addEventListener("pointerup", handleStop, options);
|
||
window.addEventListener("touchstart", handleEvents, options);
|
||
window.addEventListener("touchmove", handleEvents, options);
|
||
window.addEventListener("touchend", handleStop, options);
|
||
|
||
return () => {
|
||
window.removeEventListener("deviceorientation", handleOrientation);
|
||
window.removeEventListener("pointerdown", handleEvents);
|
||
window.removeEventListener("pointermove", handleEvents);
|
||
window.removeEventListener("pointerup", handleStop);
|
||
window.removeEventListener("touchstart", handleEvents);
|
||
window.removeEventListener("touchmove", handleEvents);
|
||
window.removeEventListener("touchend", handleStop);
|
||
};
|
||
}, [mounted]);
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
className="absolute inset-0 overflow-hidden bg-transparent pointer-events-none"
|
||
></div>
|
||
);
|
||
};
|