Files
netrunner-landing/matrix-engine/NetrunnerMatrix.tsx
T
2026-07-02 23:13:24 +07:00

320 lines
12 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, { 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>
);
};