Files
netrunner-landing/matrix-engine/NetrunnerMatrix.tsx
T
2026-04-30 17:31:50 +07:00

275 lines
8.7 KiB
TypeScript

"use client";
import React, { useRef, useEffect, useState, useCallback } from "react";
const ENGINE_VERSION = "3.0.3";
export interface NetrunnerMatrixProps {
isSecure?: boolean;
assetsPath?: string;
isDarkMode?: boolean;
showEyes?: boolean; // Добавлен флаг глаз
}
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);
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 || "ontouchstart" in window;
}, []);
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. ИНИЦИАЛИЗАЦИЯ И УПРАВЛЕНИЕ ВОРКЕРОМ ===
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";
});
}
};
// --- ПРОСТАЯ И НАДЕЖНАЯ ОПТИМИЗАЦИЯ ПАУЗЫ ---
// Мы не трогаем IntersectionObserver, который мог конфликтовать с z-index,
// а просто ставим на паузу, когда приложение/вкладка свернуто.
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;
workerRef.current?.postMessage({
type: "RESIZE",
payload: {
logicalWidth: newWidth,
logicalHeight: newHeight,
pixelScale: optimalScale,
assetsPath,
eyeY: getEyeY(),
isMobile: newWidth <= 768,
fontSize: newWidth <= 768 ? 12 : 16,
isLowQuality: newWidth <= 768 || 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]);
// === 2. СИНХРОНИЗАЦИЯ ТЕМЫ И VPN ===
useEffect(() => {
if (workerRef.current && mounted) {
workerRef.current.postMessage({
type: "THEME",
payload: {
isVpnOn: isSecure,
isDarkMode: isDarkMode,
},
});
}
}, [isSecure, isDarkMode, mounted]);
// === 3. ИНТЕРАКТИВНОСТЬ (ОРИГИНАЛЬНЫЙ РАБОЧИЙ КОД) ===
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 (
// Оставляем pointer-events-none, раз он стабильно работает в твоем окружении
<div
ref={containerRef}
className="absolute inset-0 overflow-hidden bg-transparent pointer-events-none"
></div>
);
};