383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
|
|
import React, { useMemo } from 'react';
|
||
|
|
import { SeasonalOverlayProps } from '../types';
|
||
|
|
import {
|
||
|
|
animGridScroll,
|
||
|
|
animScanRoll,
|
||
|
|
animScreenGlow,
|
||
|
|
animCrtFlicker,
|
||
|
|
animChromaShift,
|
||
|
|
animSparkleDrift,
|
||
|
|
animSparkleTwinkle,
|
||
|
|
animCoinBlink,
|
||
|
|
animScoreBlip,
|
||
|
|
} from './Arcade.css';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ArcadeOverlay — retro synthwave CRT.
|
||
|
|
*
|
||
|
|
* A full-screen, pointer-events:none ambient decoration. The parent supplies a
|
||
|
|
* fixed inset:0 overflow:hidden pointer-events:none container at the correct
|
||
|
|
* z-index, so this component only returns absolutely-positioned aria-hidden
|
||
|
|
* children and never sets position:fixed / z-index / pointer-events.
|
||
|
|
*
|
||
|
|
* Composition (back to front):
|
||
|
|
* 1. near-black synthwave ambient wash (magenta sky-glow up top, cyan/purple
|
||
|
|
* pool toward the floor) — layered oklch gradients for depth
|
||
|
|
* 2. a neon perspective grid receding to a vanishing point on the horizon,
|
||
|
|
* scrolling toward the viewer via transform translateY (never bg-position)
|
||
|
|
* 3. a soft horizon sun-glow + thin neon horizon line where the grid meets sky
|
||
|
|
* 4. drifting pixel sparkles / neon coin-burst specks rising off the grid
|
||
|
|
* 5. fine CRT scanlines, gently rolling
|
||
|
|
* 6. a faint chromatic-aberration fringe at the screen edges
|
||
|
|
* 7. a glowing "INSERT COIN" blip + a corner SCORE HUD glyph
|
||
|
|
* 8. a CRT vignette + screen-glow that frames and protects central text
|
||
|
|
*
|
||
|
|
* All motion is transform/opacity only (compositor-friendly). When `reduced` is
|
||
|
|
* true we render a static-but-gorgeous scene: a still neon grid, steady
|
||
|
|
* scanlines + vignette, and a steady "INSERT COIN" — no `animation` anywhere,
|
||
|
|
* no flicker. The settings preview always passes reduced=true, so the still
|
||
|
|
* form stands on its own.
|
||
|
|
*/
|
||
|
|
|
||
|
|
// Synthwave neon palette in oklch. Saturated where it glows, but every layer is
|
||
|
|
// held at low opacity so it tints rather than takes over the chat beneath.
|
||
|
|
const NEON_MAGENTA = 'oklch(0.65 0.25 350)';
|
||
|
|
const NEON_CYAN = 'oklch(0.80 0.15 200)';
|
||
|
|
const GRID_PURPLE = 'oklch(0.45 0.18 300)';
|
||
|
|
|
||
|
|
// The receding grid as an inline SVG data-URI (CSP-safe, no external assets).
|
||
|
|
// It is a 1x2 vertical tile of horizontal rule lines + a single set of vertical
|
||
|
|
// lines fanning toward a top-center vanishing point. The plane is then placed
|
||
|
|
// under a CSS `perspective` rotateX so the lines genuinely recede. Scrolling the
|
||
|
|
// tile up by one tile-height (animGridScroll → translateY 50%) loops seamlessly.
|
||
|
|
function gridDataUri(): string {
|
||
|
|
const lines: string[] = [];
|
||
|
|
// Horizontal rules — denser toward the top (the horizon) for a perspective
|
||
|
|
// feel even before the CSS rotateX is applied.
|
||
|
|
const rows = [0, 16, 34, 54, 76, 100, 126, 156, 190, 228, 270, 316, 366, 420, 478, 540];
|
||
|
|
rows.forEach((y) => {
|
||
|
|
lines.push(
|
||
|
|
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
|
||
|
|
`stroke-width='1.4' stroke-opacity='0.9'/>`,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
// Vertical lines fanning out from the top-center vanishing point.
|
||
|
|
for (let i = -7; i <= 7; i += 1) {
|
||
|
|
const topX = 300 + i * 6; // tight near the horizon
|
||
|
|
const botX = 300 + i * 95; // wide at the foreground
|
||
|
|
lines.push(
|
||
|
|
`<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` +
|
||
|
|
`stroke-width='1.4' stroke-opacity='0.8'/>`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
const svg =
|
||
|
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600' ` +
|
||
|
|
`preserveAspectRatio='none'>${lines.join('')}</svg>`;
|
||
|
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||
|
|
}
|
||
|
|
|
||
|
|
type Sparkle = {
|
||
|
|
left: number; // vw
|
||
|
|
bottom: number; // % up from floor where it spawns
|
||
|
|
size: number; // px
|
||
|
|
duration: number; // s
|
||
|
|
delay: number; // s
|
||
|
|
twinkle: number; // s
|
||
|
|
hue: 'magenta' | 'cyan';
|
||
|
|
opacity: number;
|
||
|
|
};
|
||
|
|
|
||
|
|
// Hand-placed still sparkles for the reduced/static scene — a few neon specks
|
||
|
|
// resting low over the grid, away from the busy chat center.
|
||
|
|
const RESTING_SPARKLES: ReadonlyArray<{
|
||
|
|
left: number;
|
||
|
|
bottom: number;
|
||
|
|
size: number;
|
||
|
|
hue: 'magenta' | 'cyan';
|
||
|
|
opacity: number;
|
||
|
|
}> = [
|
||
|
|
{ left: 12, bottom: 18, size: 4, hue: 'cyan', opacity: 0.5 },
|
||
|
|
{ left: 26, bottom: 30, size: 3, hue: 'magenta', opacity: 0.42 },
|
||
|
|
{ left: 78, bottom: 22, size: 4, hue: 'magenta', opacity: 0.5 },
|
||
|
|
{ left: 88, bottom: 34, size: 3, hue: 'cyan', opacity: 0.4 },
|
||
|
|
{ left: 50, bottom: 14, size: 3, hue: 'cyan', opacity: 0.38 },
|
||
|
|
];
|
||
|
|
|
||
|
|
const GRID_URI = gridDataUri();
|
||
|
|
|
||
|
|
export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
||
|
|
// Deterministic sparkle field, computed ONCE. No per-frame state.
|
||
|
|
const sparkles = useMemo<Sparkle[]>(() => {
|
||
|
|
const COUNT = 16;
|
||
|
|
return Array.from({ length: COUNT }, (_, i) => ({
|
||
|
|
left: (i * 6.27 + 4) % 100,
|
||
|
|
bottom: (i * 3.7) % 28, // spawn in the lower third (over the grid)
|
||
|
|
size: 2 + (i % 3), // 2..4 px pixels
|
||
|
|
duration: 14 + (i % 6) * 2.2,
|
||
|
|
delay: -((i * 1.83) % 16),
|
||
|
|
twinkle: 1.4 + (i % 4) * 0.5,
|
||
|
|
hue: i % 2 === 0 ? 'cyan' : 'magenta',
|
||
|
|
opacity: 0.45 + (i % 3) * 0.12,
|
||
|
|
}));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const sparkleColor = (hue: 'magenta' | 'cyan') => (hue === 'cyan' ? NEON_CYAN : NEON_MAGENTA);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
{/* 1. Near-black synthwave ambient wash. Magenta sky-glow up top, a
|
||
|
|
cyan/purple pool toward the floor, and an overall dark vertical
|
||
|
|
grade. Layered oklch gradients give depth at very low opacity. */}
|
||
|
|
<div
|
||
|
|
aria-hidden="true"
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
inset: 0,
|
||
|
|
backgroundImage: [
|
||
|
|
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.16) 0%, transparent 55%)',
|
||
|
|
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.20) 0%, transparent 60%)',
|
||
|
|
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.10) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.16) 100%)',
|
||
|
|
].join(','),
|
||
|
|
contain: 'layout paint style',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* 2. The neon perspective grid. A wide, tall plane is tilted away from
|
||
|
|
the viewer with `perspective` + rotateX so its rule lines recede to
|
||
|
|
a vanishing point at the top (the horizon). It lives in the lower
|
||
|
|
half of the screen — the "floor". The inner plane scrolls upward by
|
||
|
|
one tile via transform translateY, which reads as the grid flowing
|
||
|
|
toward the viewer. Pure transform; never background-position. */}
|
||
|
|
<div
|
||
|
|
aria-hidden="true"
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
left: '-25%',
|
||
|
|
right: '-25%',
|
||
|
|
bottom: 0,
|
||
|
|
height: '62%',
|
||
|
|
overflow: 'hidden',
|
||
|
|
perspective: '280px',
|
||
|
|
perspectiveOrigin: '50% 0%',
|
||
|
|
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
||
|
|
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
||
|
|
opacity: reduced ? 0.5 : 0.62,
|
||
|
|
contain: 'layout paint style',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
left: 0,
|
||
|
|
right: 0,
|
||
|
|
top: 0,
|
||
|
|
height: '200%',
|
||
|
|
transformOrigin: 'top center',
|
||
|
|
transform: 'rotateX(74deg)',
|
||
|
|
backgroundImage: GRID_URI,
|
||
|
|
backgroundRepeat: 'repeat-y',
|
||
|
|
backgroundSize: '100% 50%',
|
||
|
|
filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))',
|
||
|
|
willChange: reduced ? undefined : 'transform',
|
||
|
|
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits
|
||
|
|
where the grid meets the sky, with a thin bright rule on top of it
|
||
|
|
to seal the vanishing point. Static (no motion) either way. */}
|
||
|
|
<div
|
||
|
|
aria-hidden="true"
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
left: '50%',
|
||
|
|
top: '38%',
|
||
|
|
width: '70%',
|
||
|
|
height: '34%',
|
||
|
|
transform: 'translate(-50%, -50%)',
|
||
|
|
backgroundImage:
|
||
|
|
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)',
|
||
|
|
contain: 'layout paint style',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<div
|
||
|
|
aria-hidden="true"
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
left: '12%',
|
||
|
|
right: '12%',
|
||
|
|
top: '38%',
|
||
|
|
height: '1.5px',
|
||
|
|
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 25%, oklch(0.92 0.10 320 / 0.95) 50%, ${NEON_CYAN} 75%, transparent 100%)`,
|
||
|
|
opacity: 0.55,
|
||
|
|
filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* 4. Drifting pixel sparkles / neon coin-burst specks. Tiny square
|
||
|
|
neon pixels rising off the grid and twinkling. The static scene uses
|
||
|
|
a small resting set instead. */}
|
||
|
|
{reduced
|
||
|
|
? RESTING_SPARKLES.map((s, i) => (
|
||
|
|
<div
|
||
|
|
key={`rest-spark-${i}`}
|
||
|
|
aria-hidden="true"
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
left: `${s.left}%`,
|
||
|
|
bottom: `${s.bottom}%`,
|
||
|
|
width: `${s.size}px`,
|
||
|
|
height: `${s.size}px`,
|
||
|
|
background: sparkleColor(s.hue),
|
||
|
|
opacity: s.opacity,
|
||
|
|
boxShadow: `0 0 6px ${sparkleColor(s.hue)}`,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
))
|
||
|
|
: sparkles.map((s, i) => (
|
||
|
|
<div
|
||
|
|
key={`spark-${i}`}
|
||
|
|
aria-hidden="true"
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
left: `${s.left}%`,
|
||
|
|
bottom: `${s.bottom}%`,
|
||
|
|
width: `${s.size}px`,
|
||
|
|
height: `${s.size}px`,
|
||
|
|
willChange: 'transform, opacity',
|
||
|
|
animation: `${animSparkleDrift} ${s.duration}s linear ${s.delay}s infinite`,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
width: '100%',
|
||
|
|
height: '100%',
|
||
|
|
background: sparkleColor(s.hue),
|
||
|
|
opacity: s.opacity,
|
||
|
|
boxShadow: `0 0 6px ${sparkleColor(s.hue)}`,
|
||
|
|
animation: `${animSparkleTwinkle} ${s.twinkle}s step-end infinite`,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
|
||
|
|
{/* 5. Fine CRT scanlines. A repeating 1px dark rule field over the whole
|
||
|
|
screen, gently rolling downward on the compositor (transform only).
|
||
|
|
Held faint so text stays crisp. The pattern is in a child taller
|
||
|
|
than the frame so the roll never reveals an edge. */}
|
||
|
|
<div
|
||
|
|
aria-hidden="true"
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
inset: 0,
|
||
|
|
overflow: 'hidden',
|
||
|
|
mixBlendMode: 'multiply',
|
||
|
|
opacity: 0.5,
|
||
|
|
contain: 'layout paint style',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
left: 0,
|
||
|
|
right: 0,
|
||
|
|
top: '-8px',
|
||
|
|
bottom: '-8px',
|
||
|
|
backgroundImage:
|
||
|
|
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.55) 0px, oklch(0.10 0.04 300 / 0.55) 1px, transparent 1px, transparent 3px)',
|
||
|
|
willChange: reduced ? undefined : 'transform',
|
||
|
|
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 6. Chromatic-aberration fringe. Two thin edge-glows — magenta and cyan —
|
||
|
|
offset a sub-pixel apart at the screen border so the frame shimmers
|
||
|
|
with an RGB split, like a misconverged tube. Animated only; in the
|
||
|
|
static scene it sits as a steady fringe. */}
|
||
|
|
<div
|
||
|
|
aria-hidden="true"
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
inset: 0,
|
||
|
|
boxShadow: `inset 2px 0 14px oklch(0.65 0.25 350 / 0.16), inset -2px 0 14px oklch(0.80 0.15 200 / 0.16)`,
|
||
|
|
opacity: reduced ? 0.6 : undefined,
|
||
|
|
willChange: reduced ? undefined : 'transform, opacity',
|
||
|
|
contain: 'layout paint style',
|
||
|
|
animation: reduced ? 'none' : `${animChromaShift} 4.5s ease-in-out infinite`,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
|
||
|
|
Static scene shows it steady (no blink). */}
|
||
|
|
<div
|
||
|
|
aria-hidden="true"
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
bottom: '5%',
|
||
|
|
left: '50%',
|
||
|
|
transform: 'translateX(-50%)',
|
||
|
|
fontFamily: '"Courier New", monospace',
|
||
|
|
fontSize: '12px',
|
||
|
|
fontWeight: 700,
|
||
|
|
letterSpacing: '0.32em',
|
||
|
|
color: NEON_CYAN,
|
||
|
|
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
|
||
|
|
userSelect: 'none',
|
||
|
|
whiteSpace: 'nowrap',
|
||
|
|
opacity: reduced ? 0.6 : undefined,
|
||
|
|
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
INSERT COIN
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 7b. Corner SCORE HUD glyph — a tiny pixel score that blips, top-left,
|
||
|
|
very low opacity so it reads as ambient chrome, not UI. */}
|
||
|
|
<div
|
||
|
|
aria-hidden="true"
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
top: '2.5%',
|
||
|
|
left: '2%',
|
||
|
|
fontFamily: '"Courier New", monospace',
|
||
|
|
fontSize: '10px',
|
||
|
|
fontWeight: 700,
|
||
|
|
letterSpacing: '0.18em',
|
||
|
|
color: NEON_MAGENTA,
|
||
|
|
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
|
||
|
|
userSelect: 'none',
|
||
|
|
whiteSpace: 'nowrap',
|
||
|
|
opacity: reduced ? 0.5 : undefined,
|
||
|
|
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 8. CRT vignette + screen-glow. A radial darkening frames the corners,
|
||
|
|
with a faint magenta tube-glow swell. The vignette protects central
|
||
|
|
chat-text contrast. Static scene holds it steady; live scene adds a
|
||
|
|
shallow breathing glow + irregular flicker, both opacity-only. */}
|
||
|
|
<div
|
||
|
|
aria-hidden="true"
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
inset: 0,
|
||
|
|
backgroundImage: [
|
||
|
|
'radial-gradient(125% 95% at 50% 46%, oklch(0.72 0.18 340 / 0.05) 0%, transparent 40%)',
|
||
|
|
'radial-gradient(120% 115% at 50% 50%, transparent 50%, oklch(0.10 0.05 310 / 0.20) 84%, oklch(0.06 0.04 305 / 0.34) 100%)',
|
||
|
|
].join(','),
|
||
|
|
contain: 'layout paint style',
|
||
|
|
opacity: reduced ? 1 : undefined,
|
||
|
|
willChange: reduced ? undefined : 'opacity',
|
||
|
|
animation: reduced
|
||
|
|
? 'none'
|
||
|
|
: `${animScreenGlow} 8s ease-in-out infinite, ${animCrtFlicker} 5.5s steps(1, end) infinite`,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|