import { useEffect, useRef, useState } from 'react'; import { isCalm, onMotionChange } from '../motion.js'; // Hintergrund "Blackwall" nach Einzelbildern aus dem Referenzvideo (Cyberpunk 2077): // - Wand-Korridor: senkrechte Lichtlinien, die perspektivisch auf einen Fluchtpunkt am Horizont zulaufen // (am Rand hoch, zur Mitte flach); Daten fließen sichtbar an ihnen herab // - Boden: Feld aus Lichtpunkten in Perspektive, das langsam auf den Betrachter zufließt // - Horizont: gleißendes Band mit wanderndem Hotspot, davor eine zerfaserte Skyline aus Blöcken // - Zeichenregen nur an den Rändern, seltener Glitch (Zeilenversatz, kein Helligkeitsblitz) // Farben: Pink-Magenta mit vereinzelten violett-blauen Linien. Ohne Animation: ein Standbild. const FPS = 24; const GLITCH_EVERY = [4500, 9000]; const GLITCH_FOR = 160; const GLYPHS = '0123456789ABCDEF#$%&*+<>/\\|'; const PINK = [255, 32, 92]; const HOT = [255, 110, 160]; const VIOLET = [140, 110, 255]; const hash = (n) => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); }; const rgba = ([r, g, b], a) => `rgba(${r},${g},${b},${Math.max(0, Math.min(1, a))})`; export default function BlackwallField() { const ref = useRef(null); const [calm, setCalm] = useState(isCalm); useEffect(() => onMotionChange(() => setCalm(isCalm())), []); useEffect(() => { const canvas = ref.current; const ctx = canvas.getContext('2d'); let w = 0; let h = 0; let dpr = 1; let lines = []; let blocks = []; let columns = []; let frame = 0; let last = 0; let nextGlitch = performance.now() + 3500; let glitchUntil = 0; function build() { const cx = w / 2; // Wandlinien: dicht (alle ~3 px), Höhe nach Perspektive lines = Array.from({ length: Math.round(w / 3) }, (_, i) => { const x = hash(i + 1) * w; const persp = Math.min(1, Math.abs(x - cx) / (w / 2)); // 0 Mitte … 1 Rand const tint = hash(i + 7); return { x, width: hash(i + 2) > 0.9 ? 2 : 1, up: h * (0.03 + 0.46 * persp ** 1.25) * (0.55 + 0.45 * hash(i + 3)), // über dem Horizont down: h * (0.01 + 0.16 * persp ** 1.4) * (0.4 + 0.6 * hash(i + 4)), // unter dem Horizont alpha: 0.08 + hash(i + 5) ** 2 * 0.55, flow: 30 + hash(i + 6) * 90, // px/s Datenfluss phase: hash(i + 8) * 10, color: tint > 0.92 ? VIOLET : tint > 0.78 ? HOT : PINK, }; }); // Zerfaserte Skyline am Horizont, zur Mitte gehäuft blocks = Array.from({ length: Math.round(w / 12) }, (_, i) => { const x = w * (0.5 + (hash(i + 101) - 0.5) * (0.4 + hash(i + 107) * 0.95)); return { x, bw: 8 + hash(i + 103) ** 2 * w * 0.08, bh: 6 + hash(i + 104) ** 1.5 * h * 0.12, lift: hash(i + 102) * 0.85, // wie weit der Block über den Horizont ragt alpha: 0.05 + hash(i + 105) * 0.26, drift: (hash(i + 106) - 0.5) * 5, }; }); // Zeichenspalten nur an den Rändern (nur auf breiten Bildschirmen) columns = []; if (w > 700) { const step = 18; for (let x = 6; x < w; x += step) { if (x > w * 0.14 && x < w * 0.86) continue; const i = columns.length; if (hash(i + 301) < 0.45) continue; columns.push({ x, speed: 40 + hash(i + 302) * 90, offset: hash(i + 303) * h * 2, len: 8 + Math.floor(hash(i + 304) * 14), seed: i }); } } } function resize() { dpr = Math.min(window.devicePixelRatio || 1, 2); w = window.innerWidth; h = window.innerHeight; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); build(); draw(performance.now()); } function draw(now) { const t = calm ? 7 : now / 1000; const cy = h * 0.52; // Horizont const cx = w * 0.5; const hotX = cx + Math.sin(t * 0.13) * w * 0.16; // wandernder Lichtfleck const breath = 0.86 + 0.1 * Math.sin(t * 0.7) + 0.04 * Math.sin(t * 1.9 + 1.3); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = '#040002'; ctx.fillRect(0, 0, w, h); ctx.globalCompositeOperation = 'lighter'; // 1) Dunst um den Horizont let g = ctx.createLinearGradient(0, cy - h * 0.4, 0, cy + h * 0.3); g.addColorStop(0, rgba(PINK, 0)); g.addColorStop(0.58, rgba(PINK, 0.26 * breath)); g.addColorStop(1, rgba(PINK, 0)); ctx.fillStyle = g; ctx.fillRect(0, cy - h * 0.4, w, h * 0.7); // 2) Boden: Lichtpunkte in Perspektive, fließen langsam heran const rows = 26; const flow = (t * 0.08) % 1; for (let r = 0; r < rows; r++) { const d = (r + flow) / rows; // 0 Horizont … 1 vorne const y = cy + (h - cy) * d ** 2.1; const spacing = 5 + 46 * d ** 1.6; const size = 0.8 + 2.4 * d; const a = (0.06 + 0.4 * d) * breath; ctx.fillStyle = rgba(PINK, a); const startK = -Math.ceil(cx / spacing); for (let k = startK; k * spacing + cx < w; k++) { const x = cx + k * spacing + ((r % 2) * spacing) / 2; const twinkle = hash(k * 13.1 + r * 7.7); if (twinkle < 0.18) continue; ctx.fillRect(x, y, size, size); } } // 3) Wandlinien mit herabfließenden Daten for (const l of lines) { const top = cy - l.up; const len = l.up + l.down; const shimmer = 0.7 + 0.3 * Math.sin(t * 1.3 + l.phase); ctx.fillStyle = rgba(l.color, l.alpha * 0.55 * shimmer); ctx.fillRect(l.x, top, l.width, len); // heller Datenpaket-Abschnitt, der nach unten wandert const seg = Math.max(6, len * 0.22); const pos = ((t * l.flow + l.phase * 100) % (len + seg)) - seg; ctx.fillStyle = rgba(l.color === PINK ? HOT : l.color, l.alpha * 1.4); ctx.fillRect(l.x, top + Math.max(0, pos), l.width, Math.min(seg, len - Math.max(0, pos))); } // 4) Zerfaserte Skyline (Blöcke ragen aus dem Band, tropfen nach unten) for (const b of blocks) { const x = (b.x + t * b.drift + w) % (w + b.bw) - b.bw * 0.5; const dist = Math.min(1, Math.abs(x + b.bw / 2 - hotX) / (w * 0.5)); const hot = (1 - dist) ** 2; const a = b.alpha * (0.4 + 1 * hot) * breath; const y = cy - b.bh * b.lift; const bg = ctx.createLinearGradient(0, y, 0, y + b.bh); bg.addColorStop(0, rgba(PINK, a * 0.25)); bg.addColorStop(0.6, rgba([255, 60 + 140 * hot, 120 + 100 * hot], a)); bg.addColorStop(1, rgba(PINK, a * 0.1)); ctx.fillStyle = bg; ctx.fillRect(x, y, b.bw, b.bh); } // 5) Lichtband: Bloom, Band, gleißender Kern; Hotspot wandert ctx.save(); ctx.translate(hotX, cy); ctx.scale(1, 0.15); let rg = ctx.createRadialGradient(0, 0, 0, 0, 0, w * 0.5); rg.addColorStop(0, rgba(HOT, 0.6 * breath)); rg.addColorStop(0.45, rgba(PINK, 0.22 * breath)); rg.addColorStop(1, rgba(PINK, 0)); ctx.fillStyle = rg; ctx.fillRect(-w * 1.5, -h * 4, w * 3, h * 8); ctx.restore(); g = ctx.createLinearGradient(0, cy - 10, 0, cy + 10); g.addColorStop(0, rgba(PINK, 0)); g.addColorStop(0.5, rgba(PINK, 0.55 * breath)); g.addColorStop(1, rgba(PINK, 0)); ctx.fillStyle = g; ctx.fillRect(0, cy - 10, w, 20); ctx.save(); ctx.translate(hotX, cy); ctx.scale(1, 0.006); rg = ctx.createRadialGradient(0, 0, 0, 0, 0, w * 0.55); rg.addColorStop(0, rgba([255, 245, 250], 1 * breath)); rg.addColorStop(0.4, rgba(HOT, 0.8 * breath)); rg.addColorStop(1, rgba(PINK, 0.15 * breath)); ctx.fillStyle = rg; ctx.fillRect(-w * 1.5, -h * 100, w * 3, h * 200); ctx.restore(); // 6) Zeichenregen an den Rändern if (columns.length) { ctx.font = '13px ui-monospace, monospace'; ctx.textBaseline = 'top'; for (const c of columns) { const head = ((c.offset + t * c.speed) % (h + c.len * 16)) - c.len * 16; for (let k = 0; k < c.len; k++) { const y = head - k * 16; if (y < -16 || y > h) continue; const ch = GLYPHS[Math.floor(hash(c.seed * 31 + k * 7 + Math.floor(t * 3) * (k === 0 ? 1 : 0)) * GLYPHS.length)]; ctx.fillStyle = k === 0 ? 'rgba(255,200,220,0.55)' : rgba(PINK, 0.32 * (1 - k / c.len)); ctx.fillText(ch, c.x, y); } } } // 7) Oben und unten ins Schwarz (dort stehen Titel und Fußzeile) ctx.globalCompositeOperation = 'source-over'; g = ctx.createLinearGradient(0, 0, 0, h); g.addColorStop(0, 'rgba(4,0,2,0.95)'); g.addColorStop(0.22, 'rgba(4,0,2,0.5)'); g.addColorStop(0.42, 'rgba(4,0,2,0.04)'); g.addColorStop(0.62, 'rgba(4,0,2,0.04)'); g.addColorStop(0.85, 'rgba(4,0,2,0.5)'); g.addColorStop(1, 'rgba(4,0,2,0.95)'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); // 8) Glitch: Streifen seitlich versetzt if (!calm && now < glitchUntil) { ctx.setTransform(1, 0, 0, 1, 0, 0); for (let b = 0; b < 5; b++) { const sy = Math.floor((0.25 + hash(now * 0.0007 + b) * 0.5) * canvas.height); const sh = Math.floor((4 + hash(b + now * 0.0013) * 30) * dpr); const shift = Math.floor((hash(b * 3 + now * 0.0021) - 0.5) * 120 * dpr); ctx.drawImage(canvas, 0, sy, canvas.width, sh, shift, sy, canvas.width, sh); } } } function loop(now) { frame = requestAnimationFrame(loop); if (document.hidden || now - last < 1000 / FPS) return; last = now; if (now > nextGlitch) { glitchUntil = now + GLITCH_FOR; nextGlitch = now + GLITCH_EVERY[0] + Math.random() * (GLITCH_EVERY[1] - GLITCH_EVERY[0]); } draw(now); } resize(); window.addEventListener('resize', resize); if (!calm) frame = requestAnimationFrame(loop); return () => { cancelAnimationFrame(frame); window.removeEventListener('resize', resize); }; }, [calm]); return