This commit is contained in:
2026-04-25 17:29:50 +07:00
parent dc5a28d159
commit 44d1a87790
8 changed files with 475 additions and 283 deletions
-5
View File
@@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+6 -4
View File
@@ -5,14 +5,16 @@ cd matrix-engine
# Собираем проект # Собираем проект
wasm-pack build --target web wasm-pack build --target web
# Копируем worker в pkg
cp matrix_worker.js pkg/
# Возвращаемся в корень # Возвращаемся в корень
cd .. cd ..
# Создаем папку, если её нет (-p не выдаст ошибку, если она уже есть) # Создаем папку, если её нет
mkdir -p public/wasm-matrix mkdir -p public/wasm-matrix
# Копируем конкретные файлы из pkg в public # Копируем все из pkg в public
cp matrix-engine/pkg/matrix_engine_bg.wasm public/wasm-matrix/ cp -r matrix-engine/pkg/* public/wasm-matrix/
cp matrix-engine/pkg/matrix_engine.js public/wasm-matrix/
echo "✅ Wasm Engine updated in public/wasm-matrix/" echo "✅ Wasm Engine updated in public/wasm-matrix/"
+14
View File
@@ -47,6 +47,18 @@ export const NetrunnerMatrix: React.FC<NetrunnerMatrixProps> = ({
if (!canvas || !container || hasTransferred.current) return; if (!canvas || !container || hasTransferred.current) return;
const getEyeY = (scale) => {
const anchor = document.getElementById("eye-anchor");
const canvas = canvasRef.current;
if (anchor && canvas) {
const ar = anchor.getBoundingClientRect();
const cr = canvas.getBoundingClientRect();
return (ar.top - cr.top + ar.height / 2) * scale;
}
return 0;
};
const isMobile = checkIsMobile(); const isMobile = checkIsMobile();
const pixelScale = isMobile const pixelScale = isMobile
? 1.0 ? 1.0
@@ -75,6 +87,7 @@ export const NetrunnerMatrix: React.FC<NetrunnerMatrixProps> = ({
canvas: offscreen, canvas: offscreen,
width: initialWidth, width: initialWidth,
height: initialHeight, height: initialHeight,
eyeY: getEyeY(pixelScale),
isMobile, isMobile,
isVpnOn: isSecure, isVpnOn: isSecure,
isDarkMode, isDarkMode,
@@ -101,6 +114,7 @@ export const NetrunnerMatrix: React.FC<NetrunnerMatrixProps> = ({
payload: { payload: {
width: w, width: w,
height: h, height: h,
eyeY: getEyeY(scale),
isMobile: isMob, isMobile: isMob,
fontSize: isMob ? 10 : 16, fontSize: isMob ? 10 : 16,
}, },
+5 -233
View File
@@ -1,241 +1,12 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import { useState } from "react";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { motion, Variants } from "framer-motion"; import { motion } from "framer-motion";
import { Shield, EyeOff } from "lucide-react"; import { Shield, EyeOff } from "lucide-react";
import { Dictionary } from "@/lib/IDict"; import { Dictionary } from "@/lib/IDict";
import { CyberButton } from "../ui/cyber-button"; import { CyberButton } from "../ui/cyber-button";
import { NetrunnerMatrix } from "../graphics/NetrunnerMatrix"; import { NetrunnerMatrix } from "../graphics/NetrunnerMatrix";
import { useTheme } from "next-themes";
function CyberEye({
isClosed,
vpnOn,
gaze,
}: {
isClosed: boolean;
vpnOn: boolean;
gaze: { x: number; y: number; dir: string };
}) {
const eyeVariants: Variants = {
open: {
height: 72,
width: 120,
borderRadius: 36,
borderWidth: 4,
transition: { type: "spring", stiffness: 400, damping: 15 },
},
closed: {
height: 6,
width: 140,
borderRadius: 8,
borderWidth: 3,
transition: { type: "spring", stiffness: 500, damping: 15 },
},
};
const borderColor = vpnOn
? "border-purple-600 dark:border-primary shadow-[0_0_15px_rgba(139,61,255,0.3)]"
: "border-cyan-600 dark:border-secondary shadow-[0_0_15px_rgba(0,229,242,0.3)]";
return (
<motion.div
variants={eyeVariants}
initial="open"
animate={isClosed ? "closed" : "open"}
className={`relative flex items-center justify-center overflow-hidden bg-background ${borderColor} z-10 select-none`}
>
<motion.div
animate={{
x: isClosed ? 0 : gaze.x,
y: isClosed ? 0 : gaze.y,
scale: isClosed ? 0.3 : 1,
opacity: isClosed ? 0 : 1,
}}
// Физика саккады: резкий прыжок с легкой микро-упругостью в конце
transition={{ type: "spring", stiffness: 700, damping: 25, mass: 0.5 }}
className="relative flex items-center justify-center text-foreground pointer-events-none"
>
<span
className={`absolute -top-4 text-xs transition-all duration-300 ${
gaze.dir === "up"
? "opacity-100 scale-125 drop-shadow-md"
: "opacity-20"
}`}
>
</span>
<span
className={`absolute -bottom-4 text-xs transition-all duration-300 ${
gaze.dir === "down"
? "opacity-100 scale-125 drop-shadow-md"
: "opacity-20"
}`}
>
</span>
<span
className={`absolute -left-5 text-xs transition-all duration-300 ${
gaze.dir === "left"
? "opacity-100 scale-125 drop-shadow-md"
: "opacity-20"
}`}
>
</span>
<span
className={`absolute -right-5 text-xs transition-all duration-300 ${
gaze.dir === "right"
? "opacity-100 scale-125 drop-shadow-md"
: "opacity-20"
}`}
>
</span>
<span className="text-3xl z-10 drop-shadow-md"></span>
</motion.div>
{/* Лазерная полоса при закрытом глазе */}
<motion.div
className={`absolute h-0.5 w-full ${
vpnOn
? "bg-purple-600 dark:bg-primary shadow-[0_0_8px_var(--primary)]"
: "bg-cyan-600 dark:bg-secondary"
}`}
initial={{ opacity: 0 }}
animate={{ opacity: isClosed ? 1 : 0 }}
transition={{ duration: 0.2 }}
/>
</motion.div>
);
}
function CyberWatcherEyes({ vpnOn }: { vpnOn: boolean }) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [isBlinking, setIsBlinking] = useState(false);
const [gaze, setGaze] = useState({ x: 0, y: 0, dir: "center" });
const timerRef = useRef<any>(null);
const lastInteraction = useRef<number>(0);
// Классическое случайное моргание
useEffect(() => {
const schedule = () => {
timerRef.current = setTimeout(
() => {
setIsBlinking(true);
setTimeout(() => {
setIsBlinking(false);
schedule();
}, 120); // Чуть ускорили моргание
},
Math.random() * 4000 + 1000,
);
};
if (!vpnOn) schedule();
return () => clearTimeout(timerRef.current);
}, [vpnOn]);
// Единая логика взгляда (Unified Gaze Vector)
useEffect(() => {
const updateGaze = (targetX: number, targetY: number) => {
if (!wrapperRef.current) return;
// Вычисляем центр относительно "переносицы" (контейнера), а не каждого глаза
const rect = wrapperRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const dx = targetX - centerX;
const dy = targetY - centerY;
let dir = "center";
// Увеличили мертвую зону, чтобы взгляд чаще казался "сосредоточенным"
if (Math.abs(dx) < 60 && Math.abs(dy) < 60) {
dir = "center";
} else if (Math.abs(dx) > Math.abs(dy)) {
dir = dx > 0 ? "right" : "left";
} else {
dir = dy > 0 ? "down" : "up";
}
const angle = Math.atan2(dy, dx);
const maxRadius = 20;
// Небольшой коэффициент, чтобы глаза не бились о края орбиты слишком быстро
const distance = Math.min(maxRadius, Math.hypot(dx, dy) * 0.1);
setGaze({
x: Math.cos(angle) * distance,
y: Math.sin(angle) * distance,
dir,
});
};
const handleInteract = (e: MouseEvent | TouchEvent) => {
lastInteraction.current = Date.now();
let clientX, clientY;
if ("touches" in e && e.touches.length > 0) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = (e as MouseEvent).clientX;
clientY = (e as MouseEvent).clientY;
}
updateGaze(clientX, clientY);
};
window.addEventListener("mousemove", handleInteract);
window.addEventListener("touchstart", handleInteract, { passive: true });
window.addEventListener("pointerdown", handleInteract);
// Логика "живого" блуждания для мобилок
const randomLookInterval = setInterval(() => {
const isMobile = window.innerWidth <= 768 || "ontouchstart" in window;
if (isMobile && Date.now() - lastInteraction.current > 2500) {
if (Math.random() > 0.2) {
// Выбираем новую точку
const rx =
window.innerWidth * 0.1 + Math.random() * (window.innerWidth * 0.8);
const ry =
window.innerHeight * 0.1 +
Math.random() * (window.innerHeight * 0.8);
updateGaze(rx, ry);
// Микро-моргание при скачке взгляда (очень органичный эффект)
if (Math.random() > 0.6) {
setIsBlinking(true);
setTimeout(() => setIsBlinking(false), 80);
}
} else {
// Иногда смотрим прямо
updateGaze(window.innerWidth / 2, window.innerHeight / 2);
}
}
}, 1800); // Чуть чаще меняем взгляд на телефоне
return () => {
window.removeEventListener("mousemove", handleInteract);
window.removeEventListener("touchstart", handleInteract);
window.removeEventListener("pointerdown", handleInteract);
clearInterval(randomLookInterval);
};
}, []);
const shouldBeClosed = vpnOn || isBlinking;
return (
<div
ref={wrapperRef}
className="flex gap-6 md:gap-10 justify-center mb-12 h-20 items-center select-none pointer-events-none"
>
{/* Теперь оба глаза получают абсолютно одинаковое смещение */}
<CyberEye isClosed={shouldBeClosed} vpnOn={vpnOn} gaze={gaze} />
<CyberEye isClosed={shouldBeClosed} vpnOn={vpnOn} gaze={gaze} />
</div>
);
}
export function Hero({ export function Hero({
dict, dict,
@@ -266,7 +37,7 @@ export function Hero({
{/* Интерактивные элементы перехватывают клик обратно (pointer-events-auto) */} {/* Интерактивные элементы перехватывают клик обратно (pointer-events-auto) */}
<motion.div <motion.div
layout layout
className={`inline-flex items-center gap-2 px-4 py-1.5 rounded-full border text-sm font-bold mb-8 transition-colors duration-500 shadow-sm cursor-default pointer-events-auto ${ className={`inline-flex items-center gap-2 px-4 py-1.5 rounded-full border text-sm font-bold transition-colors duration-500 shadow-sm cursor-default pointer-events-auto ${
isVpnOn isVpnOn
? "bg-primary/10 border-primary/50 text-primary" ? "bg-primary/10 border-primary/50 text-primary"
: "bg-secondary/10 border-secondary/50 text-secondary" : "bg-secondary/10 border-secondary/50 text-secondary"
@@ -280,7 +51,8 @@ export function Hero({
<span>{isVpnOn ? dict.badgeSecure : dict.badge}</span> <span>{isVpnOn ? dict.badgeSecure : dict.badge}</span>
</motion.div> </motion.div>
<CyberWatcherEyes vpnOn={isVpnOn} /> {/* Якорь для глаз */}
<div id="eye-anchor" className="h-28 md:h-40 w-full flex-none mt-6 mb-4" />
<h1 className="text-4xl md:text-6xl font-extrabold tracking-tight text-foreground mb-6 font-mono cursor-default"> <h1 className="text-4xl md:text-6xl font-extrabold tracking-tight text-foreground mb-6 font-mono cursor-default">
{dict.titleStart}{" "} {dict.titleStart}{" "}
+1 -1
View File
@@ -34,7 +34,7 @@ export function CyberButton({
!disabled && [ !disabled && [
isPrimary isPrimary
? "bg-[#8B3DFF] border-[#8B3DFF] text-white shadow-lg shadow-purple-500/20 dark:bg-[radial-gradient(circle_at_center,_rgba(139,61,255,0.4)_0%,_rgba(139,61,255,0.1)_100%)] dark:border-[#8B3DFF]/50 dark:shadow-[0_0_25px_rgba(139,61,255,0.3)]" ? "bg-[#8B3DFF] border-[#8B3DFF] text-white shadow-lg shadow-purple-500/20 dark:bg-[radial-gradient(circle_at_center,_rgba(139,61,255,0.4)_0%,_rgba(139,61,255,0.1)_100%)] dark:border-[#8B3DFF]/50 dark:shadow-[0_0_25px_rgba(139,61,255,0.3)]"
: "text-[#00E5F2] border-[#00E5F2]/40 backdrop-blur-md bg-slate-900/5 border-[#00E5F2]/50 dark:bg-[#00E5F2]/5", : "text-[#0284c7] dark:text-[#00E5F2] border-[#0284c7]/40 dark:border-[#00E5F2]/40 backdrop-blur-md bg-slate-900/5 dark:bg-[#00E5F2]/5",
], ],
disabled && "cursor-not-allowed opacity-50 border-dashed", disabled && "cursor-not-allowed opacity-50 border-dashed",
className, className,
+317
View File
@@ -0,0 +1,317 @@
import init, { MatrixEngine } from "./matrix_engine.js";
let engine;
let wasm;
let ctx;
let atlasCanvas, atlasCtx;
let width, height;
let fontSize = 16;
let isMobile = false,
isVpnOn = false,
isDarkMode = true;
const chars =
"0101アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホ마미무메모야유요라릴레로와원>_±÷×=≠≈≡≤≥";
function buildAtlas() {
atlasCanvas = new OffscreenCanvas(chars.length * fontSize, fontSize * 2);
atlasCtx = atlasCanvas.getContext("2d", { alpha: false });
let r, g, b;
if (isVpnOn && isDarkMode) {
r = 139; g = 61; b = 255;
} else if (!isVpnOn && isDarkMode) {
r = 0; g = 229; b = 242;
} else if (isVpnOn && !isDarkMode) {
r = 109; g = 40; b = 217;
} else {
r = 2; g = 132; b = 199;
}
atlasCtx.fillStyle = isDarkMode ? "rgb(10, 10, 12)" : "rgb(250, 250, 252)";
atlasCtx.fillRect(0, 0, atlasCanvas.width, atlasCanvas.height);
atlasCtx.font = `bold ${fontSize}px monospace`;
atlasCtx.textBaseline = "top";
atlasCtx.textAlign = "center";
const halfFs = fontSize / 2;
atlasCtx.fillStyle = `rgb(${r}, ${g}, ${b})`;
for (let i = 0; i < chars.length; i++)
atlasCtx.fillText(chars[i], i * fontSize + halfFs, 0);
atlasCtx.fillStyle = "rgb(255, 255, 255)";
for (let i = 0; i < chars.length; i++)
atlasCtx.fillText(chars[i], i * fontSize + halfFs, fontSize);
}
function getGlitchColor(type) {
let r, g, b;
if (isVpnOn && isDarkMode) {
r = 139; g = 61; b = 255;
} else if (!isVpnOn && isDarkMode) {
r = 0; g = 229; b = 242;
} else if (isVpnOn && !isDarkMode) {
r = 109; g = 40; b = 217;
} else {
r = 2; g = 132; b = 199;
}
if (type === 0) return isDarkMode ? "rgb(10, 10, 12)" : "rgb(250, 250, 252)";
if (type === 1) return `rgba(${r},${g},${b},0.9)`;
if (type === 2) return `rgba(${255 - r},${255 - g},${255 - b},0.7)`;
if (type === 3) return "rgba(255,255,255,0.95)";
return "rgba(255,20,80,0.95)";
}
self.onmessage = async (e) => {
const { type, payload } = e.data;
if (type === "INIT") {
ctx = payload.canvas.getContext("2d", { alpha: false });
width = payload.width;
height = payload.height;
isMobile = payload.isMobile;
isVpnOn = payload.isVpnOn;
isDarkMode = payload.isDarkMode;
fontSize = payload.fontSize || 16;
wasm = await init("./matrix_engine_bg.wasm");
engine = new MatrixEngine(width, height, fontSize);
engine.set_mobile(isMobile);
engine.set_vpn_status(isVpnOn);
if (payload.eyeY) engine.set_eye_anchor(payload.eyeY);
buildAtlas();
requestAnimationFrame(render);
} else if (type === "RESIZE") {
if (!engine) return;
width = payload.width;
height = payload.height;
fontSize = payload.fontSize || 16;
ctx.canvas.width = width;
ctx.canvas.height = height;
engine.set_mobile(payload.isMobile);
engine.resize(width, height);
if (payload.eyeY) engine.set_eye_anchor(payload.eyeY);
buildAtlas();
} else if (type === "THEME") {
if (!engine) return;
isVpnOn = payload.isVpnOn;
isDarkMode = payload.isDarkMode;
engine.set_vpn_status(isVpnOn);
buildAtlas();
if (ctx) {
ctx.globalAlpha = 1.0;
ctx.fillStyle = isDarkMode ? "rgb(10, 10, 12)" : "rgb(250, 250, 252)";
ctx.fillRect(0, 0, width, height);
}
} else if (type === "MOUSE_MOVE") {
if (engine) engine.update_mouse(payload.x, payload.y);
} else if (type === "DRAW_START") {
if (engine) {
engine.set_drawing(true);
engine.trigger_glitch(payload.x, payload.y);
}
} else if (type === "DRAW_END") {
if (engine) engine.set_drawing(false);
} else if (type === "GET_EYE_DATA") {
if (!engine || !wasm) return;
const eyeLen = engine.eye_len();
if (eyeLen > 0) {
const eyePtr = engine.eye_ptr();
const eyeData = new Float32Array(wasm.memory.buffer, eyePtr, eyeLen);
self.postMessage({ type: "EYE_DATA", payload: eyeData.slice() });
}
}
};
function drawEye(ctx, cx, cy, gazeX, gazeY, dir, isClosed) {
const color = isVpnOn
? (isDarkMode ? "rgb(139, 61, 255)" : "rgb(109, 40, 217)")
: (isDarkMode ? "rgb(0, 229, 242)" : "rgb(2, 132, 199)");
const bgFill = isDarkMode ? "rgb(10, 10, 12)" : "rgb(250, 250, 252)";
// Interpolate properties based on isClosed (0.0 to 1.0)
const eyeHeight = 72 * (1 - isClosed) + 6 * isClosed;
const eyeWidth = 120 * (1 - isClosed) + 140 * isClosed;
const eyeRadius = 36 * (1 - isClosed) + 8 * isClosed;
const laserWidth = 140 * isClosed;
ctx.save();
ctx.fillStyle = bgFill;
ctx.beginPath();
ctx.roundRect(cx - eyeWidth/2, cy - eyeHeight/2, eyeWidth, eyeHeight, eyeRadius);
ctx.fill();
ctx.strokeStyle = color;
ctx.lineWidth = 4 * (1 - isClosed) + 3 * isClosed;
ctx.shadowColor = color;
ctx.shadowBlur = 15 * (1 - isClosed) + 8 * isClosed;
ctx.stroke();
if (isVpnOn && isClosed > 0.1) {
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = 8;
ctx.globalAlpha = isClosed;
ctx.fillRect(cx - laserWidth/2, cy - 1, laserWidth, 2);
}
if (isClosed < 0.99) {
const alphaMult = (1 - isClosed);
ctx.save();
ctx.translate(cx + gazeX, cy + gazeY);
// Outer cyber ring
ctx.strokeStyle = isDarkMode ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.6)";
ctx.lineWidth = 1.5;
ctx.globalAlpha = alphaMult;
ctx.beginPath();
ctx.arc(0, 0, 10, 0, Math.PI * 2);
ctx.stroke();
// Inner dot
ctx.fillStyle = isDarkMode ? "rgba(255,255,255,0.9)" : "rgba(0,0,0,0.8)";
ctx.shadowBlur = 5;
ctx.shadowColor = isDarkMode ? "white" : "black";
ctx.beginPath();
ctx.arc(0, 0, 3.5, 0, Math.PI * 2);
ctx.fill();
// Cyber crosshairs
ctx.strokeStyle = isDarkMode ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)";
ctx.lineWidth = 1;
ctx.shadowBlur = 0;
ctx.beginPath();
ctx.moveTo(-16, 0); ctx.lineTo(-8, 0);
ctx.moveTo(16, 0); ctx.lineTo(8, 0);
ctx.moveTo(0, -16); ctx.lineTo(0, -8);
ctx.moveTo(0, 16); ctx.lineTo(0, 8);
ctx.stroke();
// Arrows
ctx.fillStyle = isDarkMode ? "white" : "black";
ctx.font = "10px monospace";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.globalAlpha = (dir === 1 ? 1.0 : 0.2) * alphaMult;
ctx.fillText("▴", 0, -20);
ctx.globalAlpha = (dir === 2 ? 1.0 : 0.2) * alphaMult;
ctx.fillText("▾", 0, 22);
ctx.globalAlpha = (dir === 3 ? 1.0 : 0.2) * alphaMult;
ctx.fillText("◂", -22, 1);
ctx.globalAlpha = (dir === 4 ? 1.0 : 0.2) * alphaMult;
ctx.fillText("▸", 22, 1);
ctx.restore();
}
ctx.restore();
}
function render() {
if (!engine) return requestAnimationFrame(render);
engine.tick();
ctx.globalAlpha = 1.0;
ctx.fillStyle = isDarkMode
? "rgba(10, 10, 12, 0.2)"
: "rgba(250, 250, 252, 0.2)";
ctx.fillRect(0, 0, width, height);
const bgLen = engine.bg_len();
if (bgLen > 0) {
const bgData = new Float32Array(wasm.memory.buffer, engine.bg_ptr(), bgLen);
const bgF = fontSize * 0.6;
for (let i = 0; i < bgLen; i += 4) {
ctx.globalAlpha = bgData[i + 3];
ctx.drawImage(
atlasCanvas,
bgData[i + 2] * fontSize,
0,
fontSize,
fontSize,
bgData[i],
bgData[i + 1],
bgF,
bgF,
);
}
}
const fgLen = engine.fg_len();
const fgData = new Float32Array(wasm.memory.buffer, engine.fg_ptr(), fgLen);
for (let i = 0; i < fgLen; i += 5) {
ctx.globalAlpha = fgData[i + 4];
ctx.drawImage(
atlasCanvas,
fgData[i + 2] * fontSize,
fgData[i + 3] * fontSize,
fontSize,
fontSize,
fgData[i],
fgData[i + 1],
fontSize,
fontSize,
);
}
// Draw Eyes
const eyeLen = engine.eye_len();
if (eyeLen >= 6) {
const eyeData = new Float32Array(wasm.memory.buffer, engine.eye_ptr(), eyeLen);
const cx = eyeData[0];
const cy = eyeData[1];
const gazeX = eyeData[2];
const gazeY = eyeData[3];
const dir = eyeData[4];
const isClosed = eyeData[5];
const gap = isMobile ? 65 : 75;
ctx.globalAlpha = 1.0;
drawEye(ctx, cx - gap, cy, gazeX, gazeY, dir, isClosed);
drawEye(ctx, cx + gap, cy, gazeX, gazeY, dir, isClosed);
}
const glLen = engine.glitch_len();
if (glLen > 0) {
ctx.globalAlpha = 1.0;
const glData = new Float32Array(
wasm.memory.buffer,
engine.glitch_ptr(),
glLen,
);
for (let i = 0; i < glLen; i += 5) {
ctx.fillStyle = getGlitchColor(glData[i + 4]);
ctx.fillRect(glData[i], glData[i + 1], glData[i + 2], glData[i + 3]);
}
}
const gsLen = engine.glass_len();
if (gsLen > 0) {
const gsData = new Float32Array(
wasm.memory.buffer,
engine.glass_ptr(),
gsLen,
);
for (let i = 0; i < gsLen; i += 4) {
ctx.globalAlpha = gsData[i + 3];
ctx.drawImage(
atlasCanvas,
gsData[i + 2] * fontSize,
0,
fontSize,
fontSize,
gsData[i],
gsData[i + 1],
fontSize,
fontSize,
);
}
}
requestAnimationFrame(render);
}
+131 -38
View File
@@ -107,17 +107,31 @@ pub struct MatrixEngine {
bg_buffer: Vec<f32>, bg_buffer: Vec<f32>,
glitch_buffer: Vec<f32>, glitch_buffer: Vec<f32>,
glass_buffer: Vec<f32>, glass_buffer: Vec<f32>,
eye_buffer: Vec<f32>,
mouse_x: f32, mouse_x: f32,
mouse_y: f32, mouse_y: f32,
is_drawing: bool, is_drawing: bool,
is_mobile: bool, is_mobile: bool,
is_vpn_on: bool,
tick_count: u32, tick_count: u32,
emp_timer: f32, emp_timer: f32,
emp_x: f32, emp_x: f32,
emp_y: f32, emp_y: f32,
tear_y: f32, tear_y: f32,
tear_timer: f32, tear_timer: f32,
// Eye state
blink_timer: f32,
last_interaction: f32,
gaze_target_x: f32,
gaze_target_y: f32,
gaze_x: f32,
gaze_y: f32,
eye_closedness: f32,
eye_closedness_vel: f32,
eye_anchor_y: f32,
mobile_wander_timer: f32,
} }
#[wasm_bindgen] #[wasm_bindgen]
@@ -140,16 +154,28 @@ impl MatrixEngine {
bg_buffer: Vec::new(), bg_buffer: Vec::new(),
glitch_buffer: Vec::new(), glitch_buffer: Vec::new(),
glass_buffer: Vec::new(), glass_buffer: Vec::new(),
mouse_x: -1000.0, eye_buffer: Vec::new(),
mouse_y: -1000.0, mouse_x: width as f32 / 2.0,
mouse_y: height as f32 / 2.0,
is_drawing: false, is_drawing: false,
is_mobile: false, is_mobile: false,
is_vpn_on: false,
tick_count: 0, tick_count: 0,
emp_timer: 0.0, emp_timer: 0.0,
emp_x: 0.0, emp_x: 0.0,
emp_y: 0.0, emp_y: 0.0,
tear_y: 0.0, tear_y: 0.0,
tear_timer: 0.0, tear_timer: 0.0,
blink_timer: 0.0,
last_interaction: 0.0,
gaze_target_x: width as f32 / 2.0,
gaze_target_y: height as f32 / 2.0,
gaze_x: 0.0,
gaze_y: 0.0,
eye_closedness: 0.0,
eye_closedness_vel: 0.0,
eye_anchor_y: 0.0,
mobile_wander_timer: 0.0,
}; };
engine.init_drops(); engine.init_drops();
engine engine
@@ -195,6 +221,9 @@ impl MatrixEngine {
pub fn update_mouse(&mut self, x: f32, y: f32) { pub fn update_mouse(&mut self, x: f32, y: f32) {
self.mouse_x = x; self.mouse_x = x;
self.mouse_y = y; self.mouse_y = y;
self.gaze_target_x = x;
self.gaze_target_y = y;
self.last_interaction = 0.0;
} }
pub fn set_drawing(&mut self, is_drawing: bool) { pub fn set_drawing(&mut self, is_drawing: bool) {
@@ -205,6 +234,15 @@ impl MatrixEngine {
self.is_mobile = is_mobile; self.is_mobile = is_mobile;
} }
pub fn set_eye_anchor(&mut self, cy: f32) {
self.eye_anchor_y = cy;
}
pub fn set_vpn_status(&mut self, is_vpn_on: bool) {
self.is_vpn_on = is_vpn_on;
}
pub fn trigger_glitch(&mut self, x: f32, y: f32) { pub fn trigger_glitch(&mut self, x: f32, y: f32) {
self.emp_timer = 60.0; self.emp_timer = 60.0;
self.emp_x = x; self.emp_x = x;
@@ -215,14 +253,93 @@ impl MatrixEngine {
let block = GlitchBlock::new(x, y, &mut self.rng); let block = GlitchBlock::new(x, y, &mut self.rng);
self.glitch_blocks.push(block); self.glitch_blocks.push(block);
} }
self.update_mouse(x, y);
} }
pub fn tick(&mut self) { pub fn tick(&mut self) {
self.tick_count = self.tick_count.wrapping_add(1); self.tick_count = self.tick_count.wrapping_add(1);
self.last_interaction += 1.0;
self.glitch_buffer.clear(); self.glitch_buffer.clear();
self.glass_buffer.clear(); self.glass_buffer.clear();
// --- Eye Logic ---
// Calculate container center
let cx = self.width as f32 / 2.0;
let cy = if self.eye_anchor_y > 0.0 { self.eye_anchor_y } else { if self.is_mobile { 150.0 } else { 180.0 } };
// Random wander for mobile
if self.is_mobile && self.last_interaction > 150.0 {
self.mobile_wander_timer += 1.0;
if self.mobile_wander_timer > 108.0 { // ~1.8s
self.mobile_wander_timer = 0.0;
if self.rng.gen_bool(0.8) {
self.gaze_target_x = cx + self.rng.gen_range(-100.0, 100.0);
self.gaze_target_y = cy + self.rng.gen_range(-100.0, 100.0);
if self.rng.gen_bool(0.4) {
self.blink_timer = 12.0; // micro-blink
}
} else {
self.gaze_target_x = cx;
self.gaze_target_y = cy;
}
}
}
let dx = self.gaze_target_x - cx;
let dy = self.gaze_target_y - cy;
let mut dir = 0.0;
if dx.abs() < 60.0 && dy.abs() < 60.0 {
dir = 0.0; // center
} else if dx.abs() > dy.abs() {
dir = if dx > 0.0 { 4.0 } else { 3.0 }; // 4=right, 3=left
} else {
dir = if dy > 0.0 { 2.0 } else { 1.0 }; // 2=down, 1=up
}
let distance = (dx * dx + dy * dy).sqrt();
let max_radius = 20.0;
let target_dist = (distance * 0.1).min(max_radius);
let angle = dy.atan2(dx);
let target_gaze_x = angle.cos() * target_dist;
let target_gaze_y = angle.sin() * target_dist;
// Spring physics interpolation (smoother gaze)
self.gaze_x += (target_gaze_x - self.gaze_x) * 0.12;
self.gaze_y += (target_gaze_y - self.gaze_y) * 0.12;
// Blinking
if self.blink_timer > 0.0 {
self.blink_timer -= 1.0;
} else if !self.is_vpn_on {
if self.rng.gen_bool(0.0015) { // random blink
self.blink_timer = 16.0;
}
}
let target_closedness = if self.is_vpn_on || self.blink_timer > 0.0 { 1.0 } else { 0.0 };
// Springy blink physics (slower, smoother)
let diff = target_closedness - self.eye_closedness;
self.eye_closedness_vel += diff * 0.06; // lower stiffness (slower acceleration)
self.eye_closedness_vel *= 0.88; // higher damping (longer slide, less snappy)
self.eye_closedness += self.eye_closedness_vel;
// Clamp to prevent extreme glitches, but allow small spring bounce
if self.eye_closedness < -0.15 { self.eye_closedness = -0.15; }
if self.eye_closedness > 1.15 { self.eye_closedness = 1.15; }
self.eye_buffer.clear();
self.eye_buffer.push(cx);
self.eye_buffer.push(cy);
self.eye_buffer.push(self.gaze_x);
self.eye_buffer.push(self.gaze_y);
self.eye_buffer.push(dir);
self.eye_buffer.push(self.eye_closedness);
// --- End Eye Logic ---
if !self.is_mobile { if !self.is_mobile {
if self.rng.gen_bool(0.01) { if self.rng.gen_bool(0.01) {
let rx = self.rng.gen_range(0.0, self.width as f32); let rx = self.rng.gen_range(0.0, self.width as f32);
@@ -262,7 +379,6 @@ impl MatrixEngine {
self.bg_buffer[base_idx] = x; self.bg_buffer[base_idx] = x;
self.bg_buffer[base_idx + 1] = y; self.bg_buffer[base_idx + 1] = y;
self.bg_buffer[base_idx + 2] = char_idx as f32; self.bg_buffer[base_idx + 2] = char_idx as f32;
// Derive alpha from cell hash to avoid RNG overhead and create stable matrix walls
self.bg_buffer[base_idx + 3] = 0.02 + ((hash % 100) as f32 * 0.0008); self.bg_buffer[base_idx + 3] = 0.02 + ((hash % 100) as f32 * 0.0008);
drop.y += drop.speed; drop.y += drop.speed;
@@ -274,16 +390,8 @@ impl MatrixEngine {
let emp_rad = (60.0 - self.emp_timer) * 15.0; let emp_rad = (60.0 - self.emp_timer) * 15.0;
let emp_rad_sq = emp_rad * emp_rad; let emp_rad_sq = emp_rad * emp_rad;
let emp_r20_sq = if emp_rad > 20.0 { let emp_r20_sq = if emp_rad > 20.0 { (emp_rad - 20.0) * (emp_rad - 20.0) } else { 0.0 };
(emp_rad - 20.0) * (emp_rad - 20.0) let emp_r80_sq = if emp_rad > 80.0 { (emp_rad - 80.0) * (emp_rad - 80.0) } else { 0.0 };
} else {
0.0
};
let emp_r80_sq = if emp_rad > 80.0 {
(emp_rad - 80.0) * (emp_rad - 80.0)
} else {
0.0
};
let fg_spacing = if self.is_mobile { 2.0 } else { 1.0 } * self.font_size as f32; let fg_spacing = if self.is_mobile { 2.0 } else { 1.0 } * self.font_size as f32;
@@ -336,7 +444,6 @@ impl MatrixEngine {
.wrapping_add(time_shift); .wrapping_add(time_shift);
let mut char_idx = (hash as usize) % self.charset_len; let mut char_idx = (hash as usize) % self.charset_len;
// Derive visual traits from hash to save massive RNG overhead
let mut is_head = (hash % 100) < 15; let mut is_head = (hash % 100) < 15;
let mut target_alpha = if is_head { 1.0 } else { 0.6 }; let mut target_alpha = if is_head { 1.0 } else { 0.6 };
@@ -406,28 +513,14 @@ impl MatrixEngine {
self.glass_symbols.retain(|s| s.life > 0.0); self.glass_symbols.retain(|s| s.life > 0.0);
} }
pub fn fg_ptr(&self) -> *const f32 { pub fn fg_ptr(&self) -> *const f32 { self.fg_buffer.as_ptr() }
self.fg_buffer.as_ptr() pub fn fg_len(&self) -> usize { self.fg_buffer.len() }
} pub fn bg_ptr(&self) -> *const f32 { self.bg_buffer.as_ptr() }
pub fn fg_len(&self) -> usize { pub fn bg_len(&self) -> usize { self.bg_buffer.len() }
self.fg_buffer.len() pub fn glitch_ptr(&self) -> *const f32 { self.glitch_buffer.as_ptr() }
} pub fn glitch_len(&self) -> usize { self.glitch_buffer.len() }
pub fn bg_ptr(&self) -> *const f32 { pub fn glass_ptr(&self) -> *const f32 { self.glass_buffer.as_ptr() }
self.bg_buffer.as_ptr() pub fn glass_len(&self) -> usize { self.glass_buffer.len() }
} pub fn eye_ptr(&self) -> *const f32 { self.eye_buffer.as_ptr() }
pub fn bg_len(&self) -> usize { pub fn eye_len(&self) -> usize { self.eye_buffer.len() }
self.bg_buffer.len()
}
pub fn glitch_ptr(&self) -> *const f32 {
self.glitch_buffer.as_ptr()
}
pub fn glitch_len(&self) -> usize {
self.glitch_buffer.len()
}
pub fn glass_ptr(&self) -> *const f32 {
self.glass_buffer.as_ptr()
}
pub fn glass_len(&self) -> usize {
self.glass_buffer.len()
}
} }