"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-движком. Сама ничего не рисует и не считает — * только создаёт , один раз передаёт владение им в 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 = ({ isSecure = false, assetsPath = "/wasm-matrix", isDarkMode = true, showEyes = true, }) => { const containerRef = useRef(null); const workerRef = useRef(null); // canvas.transferControlToOffscreen() можно вызвать РОВНО ОДИН РАЗ за всё // время жизни конкретного 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 (
); };